Interactive Forestry Mechanics in Pygame
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)