Pygameでスターデュー・ヴァレー風ゲームを実装:土地の耕作機能

一.目標

耕作可能なエリアで鍬を使用して、土地を耕作できるようにする

二.コード実装

今後の多くの操作は土壌に依存します。耕作だけでなく、植え付け、水やり、収穫なども含まれます。そのため、これらの機能を担当する新しいファイルを作成し、そのファイルをsoil.pyと名付けます。

まずは必要なモジュールをインポートします。

次に、すべての土地を管理するクラスを作成します。土地への操作はすべてこのクラスを通じて行われます。

今後all_spritesスプライトグループが必要になるため、まずパラメータとして渡します。

また、耕作されたすべての土地を保存するスプライトグループも必要です。

さらに、耕作された土地は通常の土地とは異なる見た目になるため、耕作された土地の画像もインポートする必要があります。

.tmxファイルからわかるように、マップは多くの64×64ピクセルの小さなタイルに分かれており、それぞれのタイルを独立した土地として扱うことができます。

各土地が耕作可能かどうか、すでに耕作されているか、作物が植えられているか、水を与えられているかなど、これらの状態を保存する必要があります。そのため、3次元のリストを作成します。その最初の2次元はマップの行と列、つまり土地ブロックの座標を表し、3次元目はその土地ブロックの一連のプロパティを格納します。

このようなリストを取得するために、以下の関数を作成しました。もちろん、tmxファイルをインポートするためにpytmxツールキットを使用するため、ファイルの先頭でインポートすることを忘れないでください。

提供されたtmxファイルには「Farmable」というレイヤーがあり、これは耕作可能な土地の範囲を示す緑色のブロックで構成されています。

これにより、self.grid変数でどの土地ブロックが耕作可能か確認できます。土地ブロックのリストに'F'というプロパティがある場合、それは耕作可能であることを意味します。

init関数で上記で作成した関数を呼び出します。

現時点では、行と列のどの土地ブロックが耕作可能かはわかっていますが、この座標はtmxファイルに対するものです。そのため、pygameの座標系に対して耕作可能なエリアを取得する必要があります。そのため、以下の関数があります。

この関数は、耕作可能なすべての土地ブロックを(x座標、y座標、幅、高さ)の形式で、hit_rectsリストに保存します。

もちろん、init関数内で呼び出して実行する必要があります。

次に、プレイヤーが土地を耕作する効果を実装します。

考え方は、プレイヤーが鍬を使用すると、先ほど取得したリストhit_rects、つまり耕作可能なすべての土地ブロックを反復処理し、土地ブロックとプレイヤーの鍬の座標が重なっている場合、プレイヤーがその土地を耕作していることを意味し、次に他の操作を行います。

その前に、SoilLayerクラスのインスタンスを作成し、playerからこのオブジェクトのhit_rectsにアクセスできるようにする必要があります。

まずオブジェクトのインスタンスを作成し、level.pyに移動してモジュールをインポートします。

次にオブジェクトをインスタンス化します。理論的にはsetup関数内に書くべきですが、元の作者はinit関数に書いています。元の作者が書いたロジックは本当に乱れており、これは一貫した動画であるため、多くのことが考慮されていません。

実際には、playerオブジェクトをインスタンス化する前にSoilLayerをインスタンス化するだけで十分です。どこに書くかは問題ありません。

次に、このオブジェクトをパラメータとしてplayerに渡します。

player.pyに移動してこのパラメータを受け取ります。

次に、プレイヤーが鍬を使用する場合、soil_layerのget_hit関数を呼び出すことができます。

そしてget_hit関数はsoli.py内で完成させる必要があります。

耕作可能なすべての土地をhit_rectsリストで反復処理し、土地が渡された座標と重なっている場合、その土地を耕作していることを意味します。この時点で、grid内のこの土地ブロックのリストに'X'というマークを追加して、この土地が耕作されたことを示します。次に、耕作された土地のテクスチャを変更する必要があります。この機能はcreate_soil_tiles関数に記述します。

まず、私たちの考え方を明確にしましょう。土地を耕作した後、新しいスプライトを作成し、そのスプライトを初期化時に作成したsoil_spritesスプライトグループに追加する必要があります。また、描画するためにall_spritesスプライトグループにも追加し、スプライトのテクスチャを初期化時にインポートした耕作された土地のテクスチャに設定します。そうすれば、スプライトを描画するだけで視覚効果が達成できます。

create_soil_tiles関数を呼び出すたびに、まずsoil_spritesスプライトグループをクリアし、つまり以前に作成されたすべての耕作された土地のスプライトを削除し、次にgridをクエリして、'X'マークのついたすべての土地ブロックの位置に耕作された土地のスプライトを作成します。

実際には、これは非常に非効率なアルゴリズムです。新しい土地を耕作するたびに、元の作者はどの土地が新しく耕作されたか知りません。以前に作成されたスプライトをすべて削除し、その後すべての耕作された場所を再作成するだけです。

各耕作では、大量のスプライトの削除と作成が繰り返され、耕作する土地が多すぎるとゲームが非常にカクつく原因になります。

解決策の一つは、get_hitで新しく耕作された土地ブロックの座標を取得しているので、なぜそこで直接SoilTileを作成しないのか、わざわざ関数を呼び出してその関数内ですべての土地ブロックを反復処理する必要があるのかということです。

後で、以下のような効果を実現するために、このように書かれていることがわかりました:

隣接する土地がある場合、自動的に大きな土地に連結されます

原理は、土地の左右の上下に土地があるかどうかによって、いくつかの画像に分けられる

そして描画するたびに、各土地の上下左右にそれぞれ土地があるかどうかを判断し、対応する画像を選択して描画する

新しい土地を追加するたびに、この関数を呼び出すと、すべての土地を反復処理し、さらに多くの判断を加える必要があります

このように書くと非常にカクつきます

そこで、この効果を実現できる他のアルゴリズムがないか試してみましたが、うまくいかないことがわかり、この効果を諦めることにしました

実際には、隣接する土地を一つに繋げなくても、視覚的にはそれほど悪くありません

こうすれば、create_soil_tiles関数は削除またはコメントアウトできます

ここまでで、まだSoilTileクラスは書いていません。これは単純に表示用のスプライトです

これでゲームに入ると、土地を耕作でき、耕作した土地が多すぎてもカクつきません

元の作者の元のプログラムを実行したところ、20以上の土地を耕作すると、もうカクつきすぎて、キャラクターはほとんど瞬間移動し、基本的にプレイ性が0になってしまいました。皆さんも元のコードを実行して、実際にどれだけカクつくか試してみてください

三.完全なコード

soil.py:

import pygame

from settings import *

from pytmx.util_pygame import load_pygame

class SoilPlot(pygame.sprite.Sprite):
    def __init__(self, position, surface, groups):
       super().__init__(groups)
       self.image = surface
       self.rect = self.image.get_rect(topleft = position)
       self.z = LAYERS['soil']

class FarmlandManager:
    def __init__(self, all_sprites):
        self.all_sprites = all_sprites
        self.tilled_soil = pygame.sprite.Group()
        self.tilled_surface = pygame.image.load('../graphics/soil/o.png')
        self.create_farmland_grid()
        self.create_interaction_areas()

    def create_farmland_grid(self):
        ground = pygame.image.load('../graphics/world/ground.png')
        horizontal_tiles, vertical_tiles = ground.get_width() // TILE_SIZE, ground.get_height() // TILE_SIZE
        self.land_grid = [[[] for col in range(horizontal_tiles)] for row in range(vertical_tiles)]
        
        for x, y, _ in load_pygame('../data/map.tmx').get_layer_by_name('Farmable').tiles():
            self.land_grid[y][x].append('T')

    def create_interaction_areas(self):
        self.interaction_rects = []
        for row_index, row in enumerate(self.land_grid):
            for col_index, cell in enumerate(row):
                if 'T' in cell:
                    x_pos = col_index * TILE_SIZE
                    y_pos = row_index * TILE_SIZE
                    area = pygame.Rect(x_pos, y_pos, TILE_SIZE, TILE_SIZE)
                    self.interaction_rects.append(area)

    def till_soil(self, cursor_position):
        for area in self.interaction_rects:
            if area.collidepoint(cursor_position):
                grid_x = area.x // TILE_SIZE
                grid_y = area.y // TILE_SIZE
                
                if 'T' in self.land_grid[grid_y][grid_x]:
                    self.land_grid[grid_y][grid_x].append('T')
                    SoilPlot(
                        position=(area.x, area.y),
                        surface=self.tilled_surface,
                        groups=[self.all_sprites, self.tilled_soil])

level.py:

import pygame

from settings import *

from player import Player

from overlay import Overlay

from sprites import *

from pytmx.util_pygame import load_pygame

from support import *

from transition import Transition

from soil import FarmlandManager

class GameLevel:
    def __init__(self):
        self.display_surface = pygame.display.get_surface()
        self.all_sprites = CameraGroup()
        self.collision_sprites = pygame.sprite.Group()
        self.tree_sprites = pygame.sprite.Group()
        self.interaction_sprites = pygame.sprite.Group()
        
        self.setup()
        self.ui_overlay = Overlay(self.player)
        self.scene_transition = Transition(self.reset_level, self.player)

    def setup(self):
        self.farmland = FarmlandManager(self.all_sprites)
        
        tmx_data = load_pygame('../data/map.tmx')
        
        for layer in ['HouseFloor', 'HouseFurnitureBottom']:
            for x, y, surface in tmx_data.get_layer_by_name(layer).tiles():
                Generic((x * TILE_SIZE, y * TILE_SIZE), surface, self.all_sprites, LAYERS['house bottom'])
        
        for layer in ['HouseWalls', 'HouseFurnitureTop', 'Fence']:
            for x, y, surface in tmx_data.get_layer_by_name(layer).tiles():
                Generic((x * TILE_SIZE, y * TILE_SIZE), surface, [self.all_sprites, self.collision_sprites])
        
        water_frames = import_folder('../graphics/water')
        for x, y, surface in tmx_data.get_layer_by_name('Water').tiles():
            Water((x * TILE_SIZE, y * TILE_SIZE), water_frames, self.all_sprites)
        
        for obj in tmx_data.get_layer_by_name('Trees'):
            Tree(
                position=(obj.x, obj.y),
                surface=obj.image,
                groups=[self.all_sprites, self.collision_sprites, self.tree_sprites],
                name=obj.name,
                player_add=self.player_add)
        
        for obj in tmx_data.get_layer_by_name('Decoration'):
            WildFlower((obj.x, obj.y), obj.image, [self.all_sprites, self.collision_sprites])
        
        for x, y, surface in tmx_data.get_layer_by_name('Collision').tiles():
            Generic((x * TILE_SIZE, y * TILE_SIZE), pygame.Surface((TILE_SIZE, TILE_SIZE)), self.collision_sprites)
        
        for obj in tmx_data.get_layer_by_name('Player'):
            if obj.name == 'Start':
                self.player = Player(
                    position=(obj.x, obj.y),
                    group=self.all_sprites,
                    collision_sprites=self.collision_sprites,
                    tree_sprites=self.tree_sprites,
                    interaction=self.interaction_sprites,
                    farmland_manager=self.farmland)
            
            if obj.name == 'Bed':
                Interaction((obj.x, obj.y), (obj.width, obj.height), self.interaction_sprites, obj.name)
        
        Generic(
            position=(0, 0),
            surface=pygame.image.load('../graphics/world/ground.png').convert_alpha(),
            groups=self.all_sprites,
            z=LAYERS['ground']
        )

    def run(self, dt):
        self.display_surface.fill('black')
        self.all_sprites.custom_draw(self.player)
        self.all_sprites.update(dt)
        self.ui_overlay.display()
        
        if self.player.sleep:
            self.scene_transition.play()

    def player_add(self, item):
        self.player.item_inventory[item] += 1

    def reset_level(self):
        for tree in self.tree_sprites.sprites():
            for apple in tree.apple_sprites.sprites():
                apple.kill()
            tree.create_fruit()

class CameraGroup(pygame.sprite.Group):
    def __init__(self):
        super().__init__()
        self.display_surface = pygame.display.get_surface()
        self.offset = pygame.math.Vector2()
    
    def custom_draw(self, player):
        self.offset.x = player.rect.centerx - SCREEN_WIDTH / 2
        self.offset.y = player.rect.centery - SCREEN_HEIGHT / 2
        
        for layer in LAYERS.values():
            for sprite in sorted(self.sprites(), key=lambda sprite: sprite.rect.centery):
                if sprite.z == layer:
                    offset_rect = sprite.rect.copy()
                    offset_rect.center -= self.offset
                    self.display_surface.blit(sprite.image, offset_rect)

player.py:

import pygame

from settings import *

from support import *

from timer import Timer

class Character(pygame.sprite.Sprite):
    def __init__(self, position, sprite_group, collision_objects, tree_objects, interactive_objects, farmland_manager):
        super().__init__(sprite_group)
        self.import_assets()
        self.status = 'down_idle'
        self.frame_index = 0
        self.image = self.animations[self.status][self.frame_index]
        self.rect = self.image.get_rect(center=position)
        self.z = LAYERS['main']
        
        self.direction = pygame.math.Vector2()
        self.position = pygame.math.Vector2(self.rect.center)
        self.speed = 200
        
        self.hitbox = self.rect.copy().inflate((-126, -70))
        self.collision_sprites = collision_objects
        
        self.timers = {
            'tool use': Timer(350, self.use_tool),
            'tool switch': Timer(200),
            'seed use': Timer(350, self.use_seed),
            'seed switch': Timer(200),
        }
        
        self.tools = ['hoe', 'axe', 'water']
        self.tool_index = 0
        self.selected_tool = self.tools[self.tool_index]
        
        self.seeds = ['corn', 'tomato']
        self.seed_index = 0
        self.selected_seed = self.seeds[self.seed_index]
        
        self.inventory = {
            'wood': 0,
            'apple': 0,
            'corn': 0,
            'tomato': 0
        }
        
        self.tree_sprites = tree_objects
        self.interaction = interactive_objects
        self.sleep = False
        self.farmland = farmland_manager

    def use_tool(self):
        if self.selected_tool == 'hoe':
            self.farmland.till_soil(self.target_position)
        
        if self.selected_tool == 'axe':
            for tree in self.tree_sprites.sprites():
                if tree.rect.collidepoint(self.target_position):
                    tree.damage()
        
        if self.selected_tool == 'water':
            pass

    def get_target_position(self):
        self.target_position = self.rect.center + PLAYER_TOOL_OFFSET[self.status.split('_')[0]]

    def use_seed(self):
        pass

    def import_assets(self):
        self.animations = {'up': [], 'down': [], 'left': [], 'right': [],
                          'right_idle': [], 'left_idle': [], 'up_idle': [], 'down_idle': [],
                          'right_hoe': [], 'left_hoe': [], 'up_hoe': [], 'down_hoe': [],
                          'right_axe': [], 'left_axe': [], 'up_axe': [], 'down_axe': [],
                          'right_water': [], 'left_water': [], 'up_water': [], 'down_water': []}
        
        for animation in self.animations.keys():
            full_path = '../graphics/character/' + animation
            self.animations[animation] = import_folder(full_path)

    def animate(self, dt):
        self.frame_index += 4 * dt
        if self.frame_index >= len(self.animations[self.status]):
            self.frame_index = 0
        self.image = self.animations[self.status][int(self.frame_index)]

    def input(self):
        keys = pygame.key.get_pressed()
        
        if not self.timers['tool use'].active and not self.sleep:
            if keys[pygame.K_UP]:
                self.direction.y = -1
                self.status = 'up'
            elif keys[pygame.K_DOWN]:
                self.direction.y = 1
                self.status = 'down'
            else:
                self.direction.y = 0
            
            if keys[pygame.K_RIGHT]:
                self.direction.x = 1
                self.status = 'right'
            elif keys[pygame.K_LEFT]:
                self.direction.x = -1
                self.status = 'left'
            else:
                self.direction.x = 0
            
            if keys[pygame.K_SPACE]:
                self.timers['tool use'].activate()
                self.direction = pygame.math.Vector2()
                self.frame_index = 0
            
            if keys[pygame.K_LSHIFT] and not self.timers['tool switch'].active:
                self.timers['tool switch'].activate()
                self.tool_index += 1
                self.tool_index = self.tool_index if self.tool_index < len(self.tools) else 0
                self.selected_tool = self.tools[self.tool_index]
            
            if keys[pygame.K_RCTRL]:
                self.timers['seed use'].activate()
                self.direction = pygame.math.Vector2()
                self.frame_index = 0
            
            if keys[pygame.K_RSHIFT] and not self.timers['seed switch'].active:
                self.timers['seed switch'].activate()
                self.seed_index += 1
                self.seed_index = self.seed_index if self.seed_index < len(self.seeds) else 0
                self.selected_seed = self.seeds[self.seed_index]
            
            if keys[pygame.K_RETURN]:
                collided_interaction = pygame.sprite.spritecollide(self, self.interaction, False)
                if collided_interaction:
                    if collided_interaction[0].name == 'Trader':
                        pass
                    else:
                        self.status = 'left_idle'
                        self.sleep = True

    def get_status(self):
        if self.direction.magnitude() == 0:
            self.status = self.status.split('_')[0] + '_idle'
        
        if self.timers['tool use'].active:
            self.status = self.status.split('_')[0] + '_' + self.selected_tool

    def update_timers(self):
        for timer in self.timers.values():
            timer.update()

    def collision(self, direction):
        for sprite in self.collision_sprites.sprites():
            if hasattr(sprite, 'hitbox'):
                if sprite.hitbox.colliderect(self.hitbox):
                    if direction == 'horizontal':
                        if self.direction.x > 0:
                            self.hitbox.right = sprite.hitbox.left
                        if self.direction.x < 0:
                            self.hitbox.left = sprite.hitbox.right
                        self.rect.centerx = self.hitbox.centerx
                        self.position.x = self.hitbox.centerx
                    
                    if direction == 'vertical':
                        if self.direction.y > 0:
                            self.hitbox.bottom = sprite.hitbox.top
                        if self.direction.y < 0:
                            self.hitbox.top = sprite.hitbox.bottom
                        self.rect.centery = self.hitbox.centery
                        self.position.y = self.hitbox.centery

    def move(self, dt):
        if self.direction.magnitude() > 0:
            self.direction = self.direction.normalize()
        
        self.position.x += self.direction.x * self.speed * dt
        self.hitbox.centerx = round(self.position.x)
        self.rect.centerx = self.hitbox.centerx
        self.collision('horizontal')
        
        self.position.y += self.direction.y * self.speed * dt
        self.hitbox.centery = round(self.position.y)
        self.rect.centery = self.hitbox.centery
        self.collision('vertical')

    def update(self, dt):
        self.input()
        self.get_status()
        self.update_timers()
        self.get_target_position()
        self.move(dt)
        self.animate(dt)

タグ: pygame ゲーム開発 Python スプライト管理 タイルベースゲーム

6月9日 20:13 投稿