about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorelioat <elioat@tilde.institute>2024-12-24 17:10:37 -0500
committerelioat <elioat@tilde.institute>2024-12-24 17:10:37 -0500
commitc462a88804170227c9eec9a75ce554dbbd59d84f (patch)
tree96c1378ecf5fb7ff14f0c3407fabcf410726012d
parent2c9409826b5d61b53ce533b57e76904dc3a96799 (diff)
downloadtour-c462a88804170227c9eec9a75ce554dbbd59d84f.tar.gz
*
-rw-r--r--html/rogue/js/world.js535
1 files changed, 304 insertions, 231 deletions
diff --git a/html/rogue/js/world.js b/html/rogue/js/world.js
index 789b5f4..2b2529a 100644
--- a/html/rogue/js/world.js
+++ b/html/rogue/js/world.js
@@ -1,3 +1,46 @@
+// Color palettes and configurations
+const NATURE_COLORS = {
+    GREENS: [
+        '#11aa33', // Darker forest green
+        '#22bb44', // Medium forest green
+        '#33cc55', // Bright forest green
+        '#118844', // Deep sea green
+        '#22aa66', // Sage green
+        '#11bb55', // Deep pine
+        '#229955', // Ocean green
+        '#33bb66', // Fresh spring green
+        '#117744', // Dark emerald
+    ],
+    TRUNK_COLORS: {
+        LIGHT: '#664422',
+        MEDIUM: '#553311',
+        DARK: '#442200'
+    }
+};
+
+const GRASS_CONFIG = {
+    MIN_BLADES: 4,
+    MAX_BLADES: 8,
+    SPREAD_FACTOR: 0.7,
+    RUSTLE_SPEED: 300,
+    WAVE_SPEED: 1000,
+    GLOW_SPEED: 500,
+    BASE_BLADE_WIDTH: 5
+};
+
+const FIR_CONFIG = {
+    ROW_COUNT: 40,
+    FEATHERS_PER_ROW: 24,
+    EDGE_FEATHER_COUNT: 40,
+    FEATHER_WIDTH: 2,
+    TAPER_POWER: 0.8,
+    CENTER_NEEDLES: 5,     // Number of needles in center column
+    NEEDLE_SPACING: 1.5,   // Spacing between center needles
+    UPWARD_TILT: 0.1,     // Amount of upward tilt (in radians)
+    ANGLE_VARIATION: 0.05, // Variation in needle angles
+    LENGTH_MULTIPLIER: 0.8 // Base length multiplier for needles
+};
+
 // Define world object types
 const WORLD_OBJECTS = {
     PLATFORM: 'platform',
@@ -35,7 +78,7 @@ const MAPLE_TYPES = {
     SMALL: {
         type: 'maple',
         width: 100,
-        height: 130,
+        height: 330,
         trunkHeight: 60,
         canopyRadius: 35,
         leafClusters: 5,
@@ -45,7 +88,7 @@ const MAPLE_TYPES = {
     MEDIUM: {
         type: 'maple',
         width: 130,
-        height: 160,
+        height: 360,
         trunkHeight: 70,
         canopyRadius: 45,
         leafClusters: 6,
@@ -55,7 +98,7 @@ const MAPLE_TYPES = {
     LARGE: {
         type: 'maple',
         width: 160,
-        height: 200,
+        height: 400,
         trunkHeight: 85,
         canopyRadius: 55,
         leafClusters: 7,
@@ -65,7 +108,7 @@ const MAPLE_TYPES = {
     BRIGHT: {
         type: 'maple',
         width: 140,
-        height: 180,
+        height: 380,
         trunkHeight: 75,
         canopyRadius: 50,
         leafClusters: 6,
@@ -75,7 +118,7 @@ const MAPLE_TYPES = {
     SAGE: {
         type: 'maple',
         width: 150,
-        height: 190,
+        height: 490,
         trunkHeight: 80,
         canopyRadius: 52,
         leafClusters: 6,
@@ -154,6 +197,22 @@ const GRASS_TYPES = {
     }
 };
 
+// Utility functions
+const utils = {
+    getRandomColorFromPalette: (palette, seed1, seed2) => {
+        const colorIndex = Math.floor(seededRandom(seed1, seed2) * palette.length);
+        return palette[colorIndex];
+    },
+
+    getBladeCount: (x, height) => {
+        const randomValue = Math.abs(seededRandom(x, height));
+        return GRASS_CONFIG.MIN_BLADES + 
+            Math.round(randomValue * (GRASS_CONFIG.MAX_BLADES - GRASS_CONFIG.MIN_BLADES));
+    },
+
+    calculateTaper: (t) => Math.pow(1 - t, FIR_CONFIG.TAPER_POWER)
+};
+
 // Create a world with platforms, trees, and rocks
 const createWorld = () => {
     const world = {
@@ -525,185 +584,122 @@ const addHatchingPattern = (ctx, x, y, width, height, color) => {
     ctx.restore();
 };
 
-// Update renderFirTree function
-const renderFirTree = (ctx, tree, groundY) => {
-    const { x, config } = tree;
-    const { width, height, canopyOffset, trunkColor, canopyColor } = config;
-    
-    // Calculate trunk dimensions - make trunk narrower
-    const trunkWidth = width/4; // Changed from /3 to /4
-    const trunkHeight = height - (height - canopyOffset)/2;
-    
-    // Draw trunk base
-    ctx.fillStyle = trunkColor;
-    ctx.fillRect(
-        x - trunkWidth/2,
-        groundY - trunkHeight,
-        trunkWidth,
-        trunkHeight
-    );
-    
-    // Add trunk hatching
-    addHatchingPattern(
-        ctx,
-        x,
-        groundY,
-        trunkWidth,
-        trunkHeight,
-        trunkColor
-    );
-
-    // Define a range of green colors for texture
-    const greenColors = [
-        '#11aa33', // Darker forest green
-        '#22bb44', // Medium forest green
-        '#33cc55', // Bright forest green
-        '#118844', // Deep sea green
-        '#22aa66', // Sage green
-        '#11bb55', // Deep pine
-        '#229955', // Ocean green
-        '#33bb66', // Fresh spring green
-        '#117744', // Dark emerald
-    ].filter(color => color !== canopyColor);
-
-    const drawFeatheredTree = (baseWidth, baseY, tipY) => {
-        // Enhanced feathering parameters
-        const rowCount = 40;
-        const feathersPerRow = 24;
-        const featherLength = baseWidth * 0.3;
-        const featherWidth = 2;
+class FirTreeRenderer {
+    static renderTrunk(ctx, x, width, height, groundY, trunkColor) {
+        const trunkWidth = width/4;
+        const trunkHeight = height;
         
-        ctx.lineWidth = featherWidth;
+        ctx.fillStyle = trunkColor;
+        ctx.fillRect(
+            x - trunkWidth/2,
+            groundY - trunkHeight,
+            trunkWidth,
+            trunkHeight
+        );
         
-        // Draw rows of feathers from bottom to top
-        for (let row = 0; row < rowCount; row++) {
-            const t = row / (rowCount - 1);
-            const rowY = baseY - (baseY - tipY) * t;
+        addHatchingPattern(ctx, x, groundY, trunkWidth, trunkHeight, trunkColor);
+    }
+
+    static renderCenterNeedles(ctx, x, rowY, taper, featherLength, greenColors) {
+        for (let i = -FIR_CONFIG.CENTER_NEEDLES; i <= FIR_CONFIG.CENTER_NEEDLES; i++) {
+            const offset = i * (FIR_CONFIG.NEEDLE_SPACING * 1.5);
             
-            // More aggressive width reduction near top
-            const taper = Math.pow(1 - t, 0.8);
-            const rowWidth = baseWidth * taper;
+            // Left needle
+            FirTreeRenderer.renderSingleNeedle(
+                ctx, x + offset, rowY, Math.PI, taper, featherLength, greenColors
+            );
             
-            // Reduce feather count more aggressively near top
-            const featherCount = Math.max(2, Math.floor(feathersPerRow * taper));
+            // Right needle
+            FirTreeRenderer.renderSingleNeedle(
+                ctx, x + offset, rowY, 0, taper, featherLength, greenColors
+            );
+        }
+    }
+
+    static renderSingleNeedle(ctx, x, y, baseAngle, taper, length, colors) {
+        const angleVar = Math.PI * 0.02;
+        const angle = baseAngle + (angleVar * seededRandom(x, y) - angleVar/2);
+        
+        ctx.strokeStyle = utils.getRandomColorFromPalette(colors, x, y);
+        ctx.beginPath();
+        ctx.moveTo(x, y);
+        ctx.lineTo(
+            x + Math.cos(angle) * length * taper,
+            y + Math.sin(angle) * length * taper
+        );
+        ctx.stroke();
+    }
+
+    static renderRowNeedles(ctx, x, rowY, rowWidth, featherCount, t, taper, featherLength, greenColors) {
+        for (let i = 0; i < featherCount; i++) {
+            const featherT = i / (featherCount - 1);
+            const startX = x - rowWidth/2 + rowWidth * featherT;
             
-            // Skip drawing if width is too small (creates sharp point)
-            if (rowWidth < 2) continue;
+            if (Math.abs(startX - x) < 1) continue;
             
-            // Draw center column of needles first
-            const centerX = x;
-            const needleCount = 5; // Number of additional needles on each side
-            const needleSpacing = 1.5; // Spacing between additional needles
-
-            for (let i = -needleCount; i <= needleCount; i++) {
-                // Calculate offset for each needle
-                const offset = i * (needleSpacing * 1.5);
-
-                // Draw a needle pointing left
-                ctx.strokeStyle = greenColors[Math.floor(seededRandom(centerX + offset - 1, rowY) * greenColors.length)];
-                ctx.beginPath();
-                ctx.moveTo(centerX + offset - 1, rowY); // Start slightly left of center
-                const leftAngle = Math.PI + (Math.PI * 0.02 * seededRandom(centerX + offset, rowY) - Math.PI * 0.01);
-                ctx.lineTo(
-                    centerX + offset + Math.cos(leftAngle) * featherLength * taper,
-                    rowY + Math.sin(leftAngle) * featherLength * taper
-                );
-                ctx.stroke();
-
-                // Draw a needle pointing right
-                ctx.strokeStyle = greenColors[Math.floor(seededRandom(centerX + offset + 1, rowY) * greenColors.length)];
-                ctx.beginPath();
-                ctx.moveTo(centerX + offset + 1, rowY); // Start slightly right of center
-                const rightAngle = 0 + (Math.PI * 0.02 * seededRandom(centerX + offset, rowY) - Math.PI * 0.01);
-                ctx.lineTo(
-                    centerX + offset + Math.cos(rightAngle) * featherLength * taper,
-                    rowY + Math.sin(rightAngle) * featherLength * taper
-                );
-                ctx.stroke();
-            }
+            const relativeX = (startX - x) / (rowWidth/2);
+            const baseAngle = relativeX < 0 ? Math.PI : 0;
+            const upwardTilt = Math.PI * FIR_CONFIG.UPWARD_TILT * t;
+            const angle = baseAngle + 
+                (FIR_CONFIG.ANGLE_VARIATION * seededRandom(startX, rowY) - FIR_CONFIG.ANGLE_VARIATION/2) - 
+                upwardTilt;
             
-            // Draw regular feathers for this row
-            for (let i = 0; i < featherCount; i++) {
-                const featherT = i / (featherCount - 1);
-                const startX = x - rowWidth/2 + rowWidth * featherT;
-                
-                // Skip the center point as we've already drawn it
-                if (Math.abs(startX - x) < 1) continue;
-                
-                // Calculate angle to point directly away from center with slight upward tilt
-                const relativeX = (startX - x) / (rowWidth/2);
-                const baseAngle = relativeX < 0 ? Math.PI : 0;
-                const upwardTilt = Math.PI * 0.1 * t;
-                const angleVariation = Math.PI * 0.05;
-                const angle = baseAngle + (angleVariation * seededRandom(startX, rowY) - angleVariation/2) - upwardTilt;
-                
-                // Reduce length near top but keep needles longer
-                const lengthMultiplier = 0.8 + (1 - t) * 0.3;
-                const finalLength = featherLength * lengthMultiplier * taper * 
-                    (0.9 + seededRandom(startX * rowY, rowY) * 0.2);
-                
-                const colorIndex = Math.floor(seededRandom(startX, rowY) * greenColors.length);
-                ctx.strokeStyle = greenColors[colorIndex];
-                
-                ctx.beginPath();
-                ctx.moveTo(startX, rowY);
-                ctx.lineTo(
-                    startX + Math.cos(angle) * finalLength,
-                    rowY + Math.sin(angle) * finalLength
-                );
-                ctx.stroke();
-            }
+            const lengthMultiplier = FIR_CONFIG.LENGTH_MULTIPLIER + (1 - t) * 0.3;
+            const finalLength = featherLength * lengthMultiplier * taper * 
+                (0.9 + seededRandom(startX * rowY, rowY) * 0.2);
+            
+            ctx.strokeStyle = utils.getRandomColorFromPalette(greenColors, startX, rowY);
+            ctx.beginPath();
+            ctx.moveTo(startX, rowY);
+            ctx.lineTo(
+                startX + Math.cos(angle) * finalLength,
+                rowY + Math.sin(angle) * finalLength
+            );
+            ctx.stroke();
         }
-        
-        // Add extra feathers at the edges with upward tilt
-        const edgeFeatherCount = 40;
-        for (let i = 0; i < edgeFeatherCount; i++) {
-            const t = i / (edgeFeatherCount - 1);
-            const taper = Math.pow(1 - t, 0.8);
+    }
+
+    static renderEdgeNeedles(ctx, x, baseWidth, baseY, tipY, featherLength, greenColors) {
+        for (let i = 0; i < FIR_CONFIG.EDGE_FEATHER_COUNT; i++) {
+            const t = i / (FIR_CONFIG.EDGE_FEATHER_COUNT - 1);
+            const taper = utils.calculateTaper(t);
             
-            // Left edge
-            const leftX = x - baseWidth/2 * taper + (baseWidth/2 * taper) * t;
-            const leftY = baseY - (baseY - tipY) * t;
-            const leftUpwardTilt = Math.PI * 0.1 * t;
-            const leftAngle = Math.PI + (Math.PI * 0.05 * seededRandom(leftX, leftY) - Math.PI * 0.025) - leftUpwardTilt;
+            if (taper <= 0.1) continue;
             
-            if (taper > 0.1) {
-                ctx.strokeStyle = greenColors[Math.floor(seededRandom(leftX, leftY) * greenColors.length)];
-                ctx.beginPath();
-                ctx.moveTo(leftX, leftY);
-                ctx.lineTo(
-                    leftX + Math.cos(leftAngle) * featherLength * taper * 1.2,
-                    leftY + Math.sin(leftAngle) * featherLength * taper * 1.2
-                );
-                ctx.stroke();
-            }
+            // Left edge
+            FirTreeRenderer.renderEdgeNeedle(
+                ctx, x, baseWidth, baseY, tipY, t, taper, 
+                featherLength, Math.PI, greenColors, true
+            );
             
             // Right edge
-            const rightX = x + baseWidth/2 * taper - (baseWidth/2 * taper) * t;
-            const rightY = baseY - (baseY - tipY) * t;
-            const rightUpwardTilt = Math.PI * 0.1 * t;
-            const rightAngle = 0 + (Math.PI * 0.05 * seededRandom(rightX, rightY) - Math.PI * 0.025) - rightUpwardTilt;
-            
-            if (taper > 0.1) {
-                ctx.strokeStyle = greenColors[Math.floor(seededRandom(rightX, rightY) * greenColors.length)];
-                ctx.beginPath();
-                ctx.moveTo(rightX, rightY);
-                ctx.lineTo(
-                    rightX + Math.cos(rightAngle) * featherLength * taper * 1.2,
-                    rightY + Math.sin(rightAngle) * featherLength * taper * 1.2
-                );
-                ctx.stroke();
-            }
+            FirTreeRenderer.renderEdgeNeedle(
+                ctx, x, baseWidth, baseY, tipY, t, taper, 
+                featherLength, 0, greenColors, false
+            );
         }
-    };
+    }
 
-    // Draw feathered tree shape
-    drawFeatheredTree(
-        width * 1.2,
-        groundY - canopyOffset,
-        groundY - height * 1.1
-    );
-};
+    static renderEdgeNeedle(ctx, x, baseWidth, baseY, tipY, t, taper, length, baseAngle, colors, isLeft) {
+        const sign = isLeft ? -1 : 1;
+        const needleX = x + sign * (baseWidth/2 * taper - (baseWidth/2 * taper) * t);
+        const needleY = baseY - (baseY - tipY) * t;
+        
+        const upwardTilt = Math.PI * 0.1 * t;
+        const angle = baseAngle + 
+            (Math.PI * 0.05 * seededRandom(needleX, needleY) - Math.PI * 0.025) - 
+            upwardTilt;
+        
+        ctx.strokeStyle = utils.getRandomColorFromPalette(colors, needleX, needleY);
+        ctx.beginPath();
+        ctx.moveTo(needleX, needleY);
+        ctx.lineTo(
+            needleX + Math.cos(angle) * length * taper * 1.2,
+            needleY + Math.sin(angle) * length * taper * 1.2
+        );
+        ctx.stroke();
+    }
+}
 
 // Helper function to darken/lighten colors
 const shadeColor = (color, percent) => {
@@ -844,83 +840,83 @@ const renderGrass = (ctx, grass, groundY, time) => {
     const { x, config } = grass;
     const { width, height, color, shadowColor, glowing } = config;
     
-    // Get or initialize grass state
+    // Initialize or get grass state
     const stateKey = `grass_${x}`;
     if (!window.gameState.world.grassStates[stateKey]) {
-        // Ensure blade count is between 4 and 8 (inclusive)
-        const minBlades = 4;
-        const maxBlades = 8;
-        // Use Math.round instead of Math.floor and ensure positive value
-        const randomValue = Math.abs(seededRandom(x, height));
-        const bladeCount = minBlades + Math.round(randomValue * (maxBlades - minBlades));
-        
-        window.gameState.world.grassStates[stateKey] = {
-            rustleAmount: 0,
-            rustleDecay: 0.95,
-            bladeCount: bladeCount
-        };
+        window.gameState.world.grassStates[stateKey] = GrassState.create(x, height);
     }
-    const state = window.gameState.world.grassStates[stateKey];
-
-    // Additional validation as a safety net
-    if (!state.bladeCount || state.bladeCount < 4) {
-        state.bladeCount = 4;
-    }
-
-    // Check for player interaction
+    const state = GrassState.validate(window.gameState.world.grassStates[stateKey]);
+    
+    // Update interaction
     const player = window.gameState.player;
-    const interactionDistance = width;
     const distanceToPlayer = Math.abs(player.x + player.width / 2 - x);
-    
-    if (distanceToPlayer < interactionDistance) {
-        state.rustleAmount = Math.min(1, state.rustleAmount + 0.3);
-    } else {
-        state.rustleAmount *= state.rustleDecay;
-    }
+    GrassState.update(state, distanceToPlayer, width);
+
+    // Handle rendering
+    setupGlowEffect(ctx, glowing, color, time);
+    renderGrassBlades(ctx, state, x, width, height, groundY, time, color, shadowColor);
+    resetGlowEffect(ctx, glowing);
+};
 
-    // Add glow effect for blue grass
+const setupGlowEffect = (ctx, glowing, color, time) => {
     if (glowing) {
         ctx.save();
         ctx.shadowColor = color;
-        ctx.shadowBlur = 10 + Math.sin(time/500) * 3;
-    }
-
-    // Draw each blade of grass
-    for (let i = 0; i < state.bladeCount; i++) {
-        // Calculate blade width - consistent regardless of blade count
-        const bladeWidth = width / 5 * 0.7;
-        
-        // Calculate blade position - cluster them together more tightly
-        const spreadFactor = 0.7; // Reduces the spread to create tighter clumps
-        const centerOffset = (i - (state.bladeCount - 1) / 2) * (width * spreadFactor / state.bladeCount);
-        const bladeX = x + centerOffset;
-        
-        // Calculate blade curve based on rustle and time
-        const rustleOffset = Math.sin(time / 300 + i) * 5 * state.rustleAmount;
-        const baseWave = Math.sin(time / 1000 + i) * 2;
-        
-        // Draw blade
-        ctx.fillStyle = i % 2 === 0 ? color : shadowColor;
-        ctx.beginPath();
-        ctx.moveTo(bladeX, groundY);
-        
-        // Control points for quadratic curve
-        const cp1x = bladeX + rustleOffset + baseWave;
-        const cp1y = groundY - height / 2;
-        const cp2x = bladeX + rustleOffset * 1.5 + baseWave;
-        const cp2y = groundY - height;
-        
-        // Draw curved blade
-        ctx.quadraticCurveTo(cp1x, cp1y, cp2x, cp2y);
-        ctx.quadraticCurveTo(cp1x + bladeWidth, cp1y, bladeX + bladeWidth, groundY);
-        ctx.fill();
+        ctx.shadowBlur = 10 + Math.sin(time/GRASS_CONFIG.GLOW_SPEED) * 3;
     }
+};
 
+const resetGlowEffect = (ctx, glowing) => {
     if (glowing) {
         ctx.restore();
     }
 };
 
+const renderGrassBlades = (ctx, state, x, width, height, groundY, time, color, shadowColor) => {
+    const bladeWidth = width / GRASS_CONFIG.BASE_BLADE_WIDTH * 0.7;
+    
+    for (let i = 0; i < state.bladeCount; i++) {
+        renderSingleBlade(
+            ctx, i, state, x, width, height, groundY, time,
+            bladeWidth, color, shadowColor
+        );
+    }
+};
+
+const renderSingleBlade = (
+    ctx, index, state, x, width, height, groundY, time,
+    bladeWidth, color, shadowColor
+) => {
+    const centerOffset = (index - (state.bladeCount - 1) / 2) * 
+        (width * GRASS_CONFIG.SPREAD_FACTOR / state.bladeCount);
+    const bladeX = x + centerOffset;
+    
+    const rustleOffset = Math.sin(time / GRASS_CONFIG.RUSTLE_SPEED + index) * 5 * state.rustleAmount;
+    const baseWave = Math.sin(time / GRASS_CONFIG.WAVE_SPEED + index) * 2;
+    
+    drawBladeCurve(
+        ctx, bladeX, groundY, height, rustleOffset, baseWave,
+        bladeWidth, index % 2 === 0 ? color : shadowColor
+    );
+};
+
+const drawBladeCurve = (
+    ctx, bladeX, groundY, height, rustleOffset, baseWave,
+    bladeWidth, color
+) => {
+    const cp1x = bladeX + rustleOffset + baseWave;
+    const cp1y = groundY - height / 2;
+    const cp2x = bladeX + rustleOffset * 1.5 + baseWave;
+    const cp2y = groundY - height;
+    
+    ctx.fillStyle = color;
+    ctx.beginPath();
+    ctx.moveTo(bladeX, groundY);
+    ctx.quadraticCurveTo(cp1x, cp1y, cp2x, cp2y);
+    ctx.quadraticCurveTo(cp1x + bladeWidth, cp1y, bladeX + bladeWidth, groundY);
+    ctx.fill();
+};
+
 // Collision detection helper
 const checkCollision = (player, platform) => {
     return player.x < platform.x + platform.width &&
@@ -979,3 +975,80 @@ const handleWorldCollisions = (player, world) => {
     
     return { ...player, jumping: !onGround };
 };
+
+class GrassState {
+    static create(x, height) {
+        return {
+            rustleAmount: 0,
+            rustleDecay: 0.95,
+            bladeCount: utils.getBladeCount(x, height)
+        };
+    }
+
+    static validate(state) {
+        if (!state.bladeCount || state.bladeCount < GRASS_CONFIG.MIN_BLADES) {
+            state.bladeCount = GRASS_CONFIG.MIN_BLADES;
+        }
+        return state;
+    }
+
+    static update(state, distanceToPlayer, interactionDistance) {
+        if (distanceToPlayer < interactionDistance) {
+            state.rustleAmount = Math.min(1, state.rustleAmount + 0.3);
+        } else {
+            state.rustleAmount *= state.rustleDecay;
+        }
+    }
+}
+
+const renderFirTree = (ctx, tree, groundY) => {
+    const { x, config } = tree;
+    const { width, height, canopyOffset, trunkColor, canopyColor } = config;
+    
+    // Setup
+    const greenColors = NATURE_COLORS.GREENS.filter(color => color !== canopyColor);
+    ctx.lineWidth = FIR_CONFIG.FEATHER_WIDTH;
+    
+    // Render trunk
+    FirTreeRenderer.renderTrunk(
+        ctx, x, width, height - (height - canopyOffset)/2, 
+        groundY, trunkColor
+    );
+    
+    const drawFeatheredTree = (baseWidth, baseY, tipY) => {
+        const featherLength = baseWidth * 0.3;
+        
+        for (let row = 0; row < FIR_CONFIG.ROW_COUNT; row++) {
+            const t = row / (FIR_CONFIG.ROW_COUNT - 1);
+            const rowY = baseY - (baseY - tipY) * t;
+            
+            const taper = utils.calculateTaper(t);
+            const rowWidth = baseWidth * taper;
+            
+            if (rowWidth < 2) continue;
+            
+            const featherCount = Math.max(2, Math.floor(FIR_CONFIG.FEATHERS_PER_ROW * taper));
+            
+            // Render center column of needles
+            FirTreeRenderer.renderCenterNeedles(ctx, x, rowY, taper, featherLength, greenColors);
+            
+            // Render row needles
+            FirTreeRenderer.renderRowNeedles(
+                ctx, x, rowY, rowWidth, featherCount, t, 
+                taper, featherLength, greenColors
+            );
+        }
+        
+        // Render edge needles
+        FirTreeRenderer.renderEdgeNeedles(
+            ctx, x, baseWidth, baseY, tipY, featherLength, greenColors
+        );
+    };
+
+    // Draw complete tree
+    drawFeatheredTree(
+        width * 1.2,
+        groundY - canopyOffset,
+        groundY - height * 1.1
+    );
+};