Fading Coder

One Final Commit for the Last Sprint

Home > Notes > Content

Interactive Forestry Mechanics in Pygame

Notes May 16 1

System Architecture and Objectives

The forestry system requires three core behaviors: probabilistic fruit generation, state-based health degradation, and directional tool collision detection. Trees spawn produce at predefined relative coordinates, lose durability when struck by an equipped axe, and transition to a stump sprite upon destruction. The player must calculate an impact point relative to their facing direction and verify spatial overlap with tree bounding boxes.

Asset Configuration and Fruit Distribution

Fruit placement relies on a static mapping that associates tree identifiers with relative offset lists. During initialization, these offsets are translated innto absolute world coordinates. Each offset undergoes a probability check before an apple instance is instantiated. The new implementation correctly stores the sprite group containers passed during instantiation to prevent unpredictable rendering order issues that commonly arise when relying on internal Pygame group resolution methods.

Health Degradation and State Transitions

Each tree maintains an internal durability counter. When the player triggers an axe action, the tree's strike handler decrements this counter. If durability reaches zero, the sprite surface swaps to a stump variant, the collision rectangle shrinks to match the reduced visual profile, and the entity is flagged as inactive. Concurrently, any attached apples are removed from the simulation by selecting a random instance from the local fruit group and invoking the sprite removal routine.

Directional Targeting and Collision Logic

Axe swings are resolved by projecting a forward vector from the player's center. A lookup table maps cardinal directions to specific offset tuples, establishing an exact world coordinate for the tool's impact zone. During the active tool window, the game iterates through registered tree entities and verifies if the projected impact coordinate intersects any tree rectangle. A successful match triggers the tree's damage routine.

Refactored Implementation

Entity and Tree Logic (sprites.py)

import pygame
from settings import *
from random import randint, choice

class WorldObject(pygame.sprite.Sprite):
    def __init__(self, coordinates, surface, group_containers, z_order):
        super().__init__(*group_containers)
        self.image = surface
        self.rect = self.image.get_rect(topleft=coordinates)
        self.z = z_order
        self.hitbox = self.rect.inflate(-self.rect.width * 0.2, -self.rect.height * 0.7)

class TimberTree(WorldObject):
    def __init__(self, pos, base_surf, group_containers, tree_type):
        super().__init__(pos, base_surf, group_containers, LAYERS['vegetation'])
        
        self._fruit_offsets = FRUIT_COORDINATES[tree_type]
        self._apple_image = pygame.image.load('../graphics/fruit/apple.png')
        self._local_fruit_group = pygame.sprite.Group()
        self._parent_groups = group_containers
        self._spawn_produce()
        
        self._durability = 5
        self._is_alive = True
        stump_variant = "large" if tree_type == "Large" else "small"
        self._stump_image = pygame.image.load(f'../graphics/stumps/{stump_variant}.png')
        self.cooldown = Timer(200)
        
    def _spawn_produce(self):
        for off_x, off_y in self._fruit_offsets:
            if randint(1, 5) <= 1:
                world_x = self.rect.left + off_x
                world_y = self.rect.top + off_y
                WorldObject(
                    coordinates=(world_x, world_y),
                    surface=self._apple_image,
                    group_containers=[self._local_fruit_group, self._parent_groups[0]],
                    z_order=LAYERS['canopy']
                )
                
    def absorb_strike(self):
        if not self._is_alive:
            return
        self._durability -= 1
        
        if self._durability <= 0:
            self.image = self._stump_image
            self.rect = self.image.get_rect(midbottom=self.rect.midbottom)
            self.hitbox = self.rect.inflate(-self.rect.width * 0.25, -self.rect.height * 0.4)
            self._is_alive = False
            self._prune_fruit()
        else:
            self._prune_fruit()
            
    def _prune_fruit(self):
        available = self._local_fruit_group.sprites()
        if available:
            choice(available).kill()

Player Interaction and Target Projection (player.py)

import pygame
from settings import *
from support import import_folder

class Character(pygame.sprite.Sprite):
    def __init__(self, spawn_coord, render_group, obstacle_group, flora_collision_group):
        super().__init__(render_group)
        self._load_animations()
        self._state = 'down_idle'
        self._frame = 0
        self.image = self._anims[self._state][self._frame]
        self.rect = self.image.get_rect(center=spawn_coord)
        self.position = pygame.math.Vector2(self.rect.center)
        self.direction = pygame.math.Vector2()
        self.move_rate = 250
        self.hitbox = self.rect.inflate(-130, -80)
        self._solid_objects = obstacle_group
        self._wood_targets = flora_collision_group
        self.impact_coordinate = self.rect.center
        
        self._active_timers = {
            'tool_use': Timer(350, self._activate_tool),
            'tool_cycle': Timer(200)
        }
        self._equipped = ['hoe', 'axe', 'water']
        self._current_tool = self._equipped[0]
        
    def _activate_tool(self):
        if self._current_tool == 'axe':
            for tree in self._wood_targets.sprites():
                if tree.rect.collidepoint(self.impact_coordinate):
                    tree.absorb_strike()
                    break
                    
    def _calculate_impact_zone(self):
        facing = self._state.split('_')[0]
        offset_vec = TOOL_RANGE_OFFSET.get(facing, (0, 0))
        self.impact_coordinate = self.rect.center + pygame.math.Vector2(offset_vec)
        
    def process_input(self):
        keys = pygame.key.get_pressed()
        if self._active_timers['tool_use'].active:
            return
            
        if keys[pygame.K_UP]:
            self.direction.y, self._state = -1, 'up'
        elif keys[pygame.K_DOWN]:
            self.direction.y, self._state = 1, 'down'
        else:
            self.direction.y = 0
            
        if keys[pygame.K_RIGHT]:
            self.direction.x, self._state = 1, 'right'
        elif keys[pygame.K_LEFT]:
            self.direction.x, self._state = -1, 'left'
        else:
            self.direction.x = 0
            
        if keys[pygame.K_SPACE]:
            self._active_timers['tool_use'].activate()
            self.direction = pygame.math.Vector2()
            self._frame = 0
            
        if keys[pygame.K_LSHIFT] and not self._active_timers['tool_cycle'].active:
            self._active_timers['tool_cycle'].activate()
            self._equipped.append(self._equipped.pop(0))
            self._current_tool = self._equipped[0]
            
    def _update_animation_state(self):
        if self.direction.magnitude() == 0:
            self._state = f"{self._state.split('_')[0]}_idle"
        if self._active_timers['tool_use'].active:
            self._state = f"{self._state.split('_')[0]}_{self._current_tool}"
            
    def handle_timers(self):
        for timer in self._active_timers.values():
            timer.update()
            
    def update(self, dt):
        self.process_input()
        self._update_animation_state()
        self.handle_timers()
        self._calculate_impact_zone()
        self._traverse_environment(dt)
        self._run_animation_cycle(dt)
        
    def _load_animations(self):
        dirs = ['up', 'down', 'left', 'right']
        states = ['', '_idle', '_hoe', '_axe', '_water']
        self._anims = {f"{d}{s}": import_folder(f'../graphics/character/{d}{s}') for d in dirs for s in states}
        
    def _run_animation_cycle(self, dt):
        self._frame += 4 * dt
        if self._frame >= len(self._anims[self._state]):
            self._frame = 0
        self.image = self._anims[self._state][int(self._frame)]
        
    def _traverse_environment(self, dt):
        if self.direction.magnitude() > 0:
            self.direction.normalize_ip()
        self.position += self.direction * self.move_rate * dt
        self.hitbox.center = self.position.round()
        self.rect.center = self.hitbox.center

Environment Assembly (level.py)

import pygame
from settings import *
from player import Character
from sprites import TimberTree, WorldObject, WildFlower
from pytmx.util_pygame import load_pygame
from support import import_folder

class GameLevel:
    def __init__(self):
        self.screen = pygame.display.get_surface()
        self.render_layer = CameraGroup()
        self.collision_bounds = pygame.sprite.Group()
        self.interactive_trees = pygame.sprite.Group()
        self._build_world()
        
    def _build_world(self):
        map_data = load_pygame('../data/map.tmx')
        
        for layer in ['HouseFloor', 'HouseFurnitureBottom']:
            for x, y, img in map_data.get_layer_by_name(layer).tiles():
                WorldObject((x * TILE_SIZE, y * TILE_SIZE), img, 
                           self.render_layer, LAYERS['house_bottom'])
                           
        for layer in ['HouseWalls', 'Fence']:
            for x, y, img in map_data.get_layer_by_name(layer).tiles():
                WorldObject((x * TILE_SIZE, y * TILE_SIZE), img, 
                           [self.render_layer, self.collision_bounds], LAYERS['structures'])
                           
        water_sprites = import_folder('../graphics/water')
        for x, y, img in map_data.get_layer_by_name('Water').tiles():
            WaterSprite((x * TILE_SIZE, y * TILE_SIZE), water_sprites, self.render_layer)
            
        for obj in map_data.get_layer_by_name('Trees'):
            TimberTree((obj.x, obj.y), obj.image,
                      [self.render_layer, self.collision_bounds, self.interactive_trees],
                      obj.name)
                      
        for obj in map_data.get_layer_by_name('Decoration'):
            WildFlower((obj.x, obj.y), obj.image, [self.render_layer, self.collision_bounds])
            
        for x, y, img in map_data.get_layer_by_name('Collision').tiles():
            WorldObject((x * TILE_SIZE, y * TILE_SIZE), 
                       pygame.Surface((TILE_SIZE, TILE_SIZE)), 
                       self.collision_bounds, LAYERS['collision'])
                       
        for obj in map_data.get_layer_by_name('Player'):
            if obj.name == 'Start':
                self.active_player = Character(
                    spawn_coord=(obj.x, obj.y),
                    render_group=self.render_layer,
                    obstacle_group=self.collision_bounds,
                    flora_collision_group=self.interactive_trees
                )
                
    def process_frame(self, dt):
        self.screen.fill('black')
        self.render_layer.custom_draw(self.active_player)
        self.render_layer.update(dt)

Related Articles

Designing Alertmanager Templates for Prometheus Notifications

How to craft Alertmanager templates to format alert messages, improving clarity and presentation. Alertmanager uses Go’s text/template engine with additional helper functions. Alerting rules referenc...

Deploying a Maven Web Application to Tomcat 9 Using the Tomcat Manager

Tomcat 9 does not provide a dedicated Maven plugin. The Tomcat Manager interface, however, is backward-compatible, so the Tomcat 7 Maven Plugin can be used to deploy to Tomcat 9. This guide shows two...

Skipping Errors in MySQL Asynchronous Replication

When a replica halts because the SQL thread encounters an error, you can resume replication by skipping the problematic event(s). Two common approaches are available. Methods to Skip Errors 1) Skip a...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.