1. Home
  2. iThink
  3. Programmazione per Ragazzi
  4. Progetto finale in Python: dall'idea al programma
Programmazione per Ragazzi Divento Programmatore · Capitolo 8

Progetto finale in Python: dall'idea al programma

Codice Python sullo schermo di un computer
Foto: JohnsonMartin · Pixabay · Licenza dei contenuti Pixabay

Divento Programmatore — Capitolo 8: Progetto finale — Crea il tuo gioco completo

È il momento di mettere tutto insieme. Hai imparato: variabili, controllo flusso, funzioni, liste/dizionari, moduli, Pygame. Ora costruiamo un gioco vero, completo, condivisibile.

Cosa imparerai

| Fase | Contenuto | | ------ | ----------- | | 1. Game Design Document (GDD) | Pianifica prima di codificare | | 2. Architettura a classi | Entity, Player, Enemy, Projectile, Particle | | 3. GameManager & State Machine | MENU, PLAYING, PAUSED, GAME_OVER | | 4. Collision Detection | AABB (rect), circolare, ottimizzazioni | | 5. Sistemi gioco | Punteggio, vite, livelli, high-score (JSON) | | 6. UI/Menu | Navigazione tastiera, HUD, schermate | | 7. Polish | Particelle, screen shake, effetti sonori | | 8. Packaging | PyInstaller → .exe Windows, .app macOS |


1. Game Design Document (GDD) — Pianifica prima

Non scrivere codice subito. Rispondi a queste domande su carta:

┌─────────────────────────────────────────────────────────────┐
│ GDD: "STAR DEFENDER"                                        │
├─────────────────────────────────────────────────────────────┤
│ CONCEPT: Sparatutto verticale, difendi la base da ondate    │
│ TARGET: 10-14 anni, partite 3-5 min                         │
│ CONTROLLI: Frecce/WASD muovi, SPAZIO spara, ESC pausa       │
│ MECCANICHE CORE:                                            │
│  - Player spara proiettili verso l'alto                     │
│  - Nemici scendono, sparano a caso                          │
│  - Power-up: fuoco rapido, scudo, vita extra                │
│  - Boss ogni 5 ondate                                       │
│ PROGRESSIONE: Ondate crescenti, velocità +10% per ondata    │
│ VITTORIA: Sopravvivi 15 ondate (o infinito con high-score)  │
│ GAME OVER: Base distrutta (vite = 0)                        │
│ ASSET NEEDED: Player, 3 nemici, proiettili, power-up, SFX  │
└─────────────────────────────────────────────────────────────┘

Regola: GDD = bussola. Se non sai cosa codificare, rileggi GDD.


2. Struttura progetto

star_defender/
├── main.py                 # Entry point
├── config.py               # Costanti, settings
├── assets/                 # Immagini, suoni, font
│   ├── images/
│   ├── sounds/
│   └── fonts/
├── core/
│   ├── __init__.py
│   ├── entity.py          # Classe base Entity
│   ├── player.py
│   ├── enemy.py
│   ├── projectile.py
│   ├── powerup.py
│   ├── particle.py
│   └── effects.py         # Screen shake, flash
├── systems/
│   ├── __init__.py
│   ├── collision.py       # Sistema collisioni
│   ├── spawn.py           # Wave manager
│   ├── score.py           # Punteggio, high-score
│   └── audio.py           # Gestione suoni
├── ui/
│   ├── __init__.py
│   ├── hud.py             # HUD in gioco
│   ├── menu.py            # Menu principale, pause, game over
│   └── button.py
└── utils/
    ├── __init__.py
    ├── vector.py          # Vettore2D (Cap. 6)
    └── helpers.py

3. Configurazione centrale: config.py

# config.py
"""Tutte le costanti di gioco in un posto solo."""

from pygame.math import Vector2 as Vec2

# Finestra
SCREEN_WIDTH = 480
SCREEN_HEIGHT = 720
FPS = 60
TITLE = "Star Defender"
ICON_PATH = "assets/images/icon.png"

# Colori (R, G, B)
BG_COLOR = (10, 10, 20)
WHITE = (255, 255, 255)
RED = (255, 60, 60)
GREEN = (60, 255, 100)
BLUE = (60, 180, 255)
YELLOW = (255, 255, 60)
CYAN = (60, 255, 255)
ORANGE = (255, 160, 60)
PURPLE = (180, 60, 255)

# Player
PLAYER_SPEED = 400
PLAYER_FIRE_RATE = 0.15      # secondi
PLAYER_LIVES = 3
PLAYER_INVULN_TIME = 1.5     # secondi dopo hit
PLAYER_SIZE = (40, 30)

# Nemici
ENEMY_BASE_SPEED = 80
ENEMY_SPEED_INCREASE = 0.1   # +10% per onda
ENEMY_FIRE_CHANCE = 0.002    # per frame
ENEMY_TYPES = {
    "basic":    {"hp": 1, "score": 10, "color": RED, "size": (30, 30)},
    "fast":     {"hp": 1, "score": 20, "color": ORANGE, "size": (25, 25)},
    "tank":     {"hp": 3, "score": 50, "color": PURPLE, "size": (45, 40)},
    "shooter":  {"hp": 2, "score": 30, "color": CYAN, "size": (35, 35)},
}

# Proiettili
PROJECTILE_SPEED = 600
PROJECTILE_PLAYER_COLOR = YELLOW
PROJECTILE_ENEMY_COLOR = RED
PROJECTILE_SIZE = (4, 12)

# Power-up
POWERUP_TYPES = {
    "rapid_fire": {"color": GREEN, "duration": 5.0, "symbol": "⚡"},
    "shield":     {"color": BLUE, "duration": 8.0, "symbol": "🛡"},
    "extra_life": {"color": ORANGE, "duration": 0, "symbol": "♥"},
    "spread":     {"color": PURPLE, "duration": 6.0, "symbol": "✦"},
}
POWERUP_SPAWN_CHANCE = 0.15
POWERUP_SPEED = 100

# Particelle
PARTICLE_LIFETIME = 0.5
PARTICLE_COUNT_EXPLOSION = 15

# Wave system
WAVE_ENEMY_COUNT_BASE = 6
WAVE_ENEMY_INCREASE = 2
BOSS_WAVE_INTERVAL = 5

# High-score
HIGHSCORE_FILE = "highscore.json"
MAX_HIGHSCORES = 10

# Stati gioco
class GameState:
    MENU = "menu"
    PLAYING = "playing"
    PAUSED = "paused"
    GAME_OVER = "game_over"
    VICTORY = "victory"

4. Entità base: core/entity.py

# core/entity.py
"""Classe base per tutti gli oggetti di gioco."""

import pygame
from pygame.math import Vector2 as Vec2
from config import *

class Entity:
    def __init__(self, x: float, y: float, width: int, height: int, color: tuple):
        self.pos = Vec2(x, y)
        self.vel = Vec2(0, 0)
        self.size = Vec2(width, height)
        self.color = color
        self.active = True
        self.rect = pygame.Rect(0, 0, width, height)
        self._update_rect()
    
    def _update_rect(self):
        self.rect.center = (int(self.pos.x), int(self.pos.y))
    
    def update(self, dt: float):
        self.pos += self.vel * dt
        self._update_rect()
    
    def draw(self, surface: pygame.Surface):
        pygame.draw.rect(surface, self.color, self.rect, border_radius=4)
    
    def is_off_screen(self) -> bool:
        return (self.pos.y < -self.size.y or 
                self.pos.y > SCREEN_HEIGHT + self.size.y or
                self.pos.x < -self.size.x or 
                self.pos.x > SCREEN_WIDTH + self.size.x)
    
    def collides_with(self, other: "Entity") -> bool:
        return self.rect.colliderect(other.rect)
    
    def distance_to(self, other: "Entity") -> float:
        return self.pos.distance_to(other.pos)
    
    def kill(self):
        self.active = False

5. Player: core/player.py

# core/player.py
"""Giocatore: movimento, sparo, power-up, invulnerabilità."""

import pygame
import time
from core.entity import Entity
from core.projectile import Projectile
from core.particle import create_explosion
from config import *

class Player(Entity):
    def __init__(self, x: float, y: float):
        super().__init__(x, y, *PLAYER_SIZE, BLUE)
        self.lives = PLAYER_LIVES
        self.score = 0
        self.fire_cooldown = 0.0
        self.invuln_timer = 0.0
        self.visible = True
        self.blink_timer = 0.0
        
        # Power-up state
        self.rapid_fire = False
        self.rapid_fire_timer = 0.0
        self.shield = False
        self.shield_timer = 0.0
        self.spread_shot = False
        self.spread_timer = 0.0
    
    def update(self, dt: float, keys: pygame.key.ScancodeWrapper, projectiles: list):
        # Movimento
        dir_x = (keys[pygame.K_RIGHT] or keys[pygame.K_d]) - \
                (keys[pygame.K_LEFT] or keys[pygame.K_a])
        dir_y = (keys[pygame.K_DOWN] or keys[pygame.K_s]) - \
                (keys[pygame.K_UP] or keys[pygame.K_w])
        
        if dir_x != 0 or dir_y != 0:
            self.vel = Vec2(dir_x, dir_y).normalize() * PLAYER_SPEED
        else:
            self.vel = Vec2(0, 0)
        
        super().update(dt)
        
        # Clamp a schermo
        self.pos.x = max(self.size.x/2, min(SCREEN_WIDTH - self.size.x/2, self.pos.x))
        self.pos.y = max(self.size.y/2, min(SCREEN_HEIGHT - self.size.y/2, self.pos.y))
        self._update_rect()
        
        # Cooldown sparo
        self.fire_cooldown = max(0, self.fire_cooldown - dt)
        
        # Sparo
        if keys[pygame.K_SPACE] and self.fire_cooldown <= 0:
            self._shoot(projectiles)
            rate = PLAYER_FIRE_RATE / 3 if self.rapid_fire else PLAYER_FIRE_RATE
            self.fire_cooldown = rate
        
        # Power-up timers
        self._update_powerups(dt)
        
        # Invulnerabilità lampeggio
        if self.invuln_timer > 0:
            self.invuln_timer -= dt
            self.blink_timer += dt
            if self.blink_timer > 0.1:
                self.visible = not self.visible
                self.blink_timer = 0
        else:
            self.visible = True
    
    def _shoot(self, projectiles: list):
        # Proiettile centrale
        projectiles.append(Projectile(
            self.pos.x, self.rect.top,
            0, -PROJECTILE_SPEED,
            PROJECTILE_PLAYER_COLOR, True
        ))
        # Spread shot
        if self.spread_shot:
            for angle in (-15, 15):
                projectiles.append(Projectile(
                    self.pos.x, self.rect.top,
                    angle, -PROJECTILE_SPEED,
                    PROJECTILE_PLAYER_COLOR, True
                ))
    
    def _update_powerups(self, dt):
        if self.rapid_fire:
            self.rapid_fire_timer -= dt
            if self.rapid_fire_timer <= 0:
                self.rapid_fire = False
        if self.shield:
            self.shield_timer -= dt
            if self.shield_timer <= 0:
                self.shield = False
        if self.spread_shot:
            self.spread_timer -= dt
            if self.spread_timer <= 0:
                self.spread_shot = False
    
    def apply_powerup(self, ptype: str):
        data = POWERUP_TYPES[ptype]
        if ptype == "rapid_fire":
            self.rapid_fire = True
            self.rapid_fire_timer = data["duration"]
        elif ptype == "shield":
            self.shield = True
            self.shield_timer = data["duration"]
        elif ptype == "extra_life":
            self.lives = min(5, self.lives + 1)
        elif ptype == "spread":
            self.spread_shot = True
            self.spread_timer = data["duration"]
    
    def take_damage(self) -> bool:
        if self.invuln_timer > 0 or self.shield:
            if self.shield:
                self.shield = False  # Scudo si consuma
            return False
        self.lives -= 1
        self.invuln_timer = PLAYER_INVULN_TIME
        return True
    
    def draw(self, surface: pygame.Surface):
        if not self.visible:
            return
        super().draw(surface)
        # Scudo visuale
        if self.shield:
            pygame.draw.circle(surface, BLUE, self.rect.center, 
                              max(self.size) // 2 + 5, 2)

6. Nemici: core/enemy.py

# core/enemy.py
"""Nemici con tipi diversi e comportamento base."""

import random
import pygame
from core.entity import Entity
from core.projectile import Projectile
from config import *

class Enemy(Entity):
    def __init__(self, x: float, y: float, etype: str = "basic"):
        data = ENEMY_TYPES[etype]
        super().__init__(x, y, *data["size"], data["color"])
        self.etype = etype
        self.hp = data["hp"]
        self.max_hp = data["hp"]
        self.score_value = data["score"]
        self.vel = Vec2(0, ENEMY_BASE_SPEED)
        self.fire_timer = random.uniform(0.5, 2.0)
        self.shoot_delay = random.uniform(1.5, 3.0)
    
    def update(self, dt: float, projectiles: list, player_pos: Vec2):
        super().update(dt)
        self.fire_timer -= dt
        
        # Sparo base (solo shooter e tank)
        if self.etype in ("shooter", "tank") and self.fire_timer <= 0:
            self._shoot(projectiles, player_pos)
            self.fire_timer = self.shoot_delay
    
    def _shoot(self, projectiles: list, target: Vec2):
        direction = (target - self.pos).normalize()
        projectiles.append(Projectile(
            self.pos.x, self.rect.bottom,
            direction.x, direction.y,
            PROJECTILE_ENEMY_COLOR, False
        ))
    
    def take_damage(self, amount: int = 1) -> bool:
        self.hp -= amount
        return self.hp <= 0
    
    def draw(self, surface: pygame.Surface):
        super().draw(surface)
        # Barra vita per tank
        if self.max_hp > 1:
            bar_w = self.size.x
            bar_h = 4
            pct = self.hp / self.max_hp
            pygame.draw.rect(surface, RED, 
                           (self.rect.left, self.rect.top - 8, bar_w, bar_h))
            pygame.draw.rect(surface, GREEN, 
                           (self.rect.left, self.rect.top - 8, bar_w * pct, bar_h))

7. Proiettili e Power-up: core/projectile.py, core/powerup.py

# core/projectile.py
"""Proiettili giocatore e nemici."""

import pygame
from core.entity import Entity
from config import *

class Projectile(Entity):
    def __init__(self, x: float, y: float, dir_x: float, dir_y: float, 
                 color: tuple, from_player: bool):
        super().__init__(x, y, *PROJECTILE_SIZE, color)
        self.from_player = from_player
        if dir_x != 0 or dir_y != 0:
            self.vel = Vec2(dir_x, dir_y).normalize() * PROJECTILE_SPEED
    
    def update(self, dt: float):
        super().update(dt)
        if self.is_off_screen():
            self.kill()
    
    def draw(self, surface: pygame.Surface):
        pygame.draw.rect(surface, self.color, self.rect, border_radius=2)
# core/powerup.py
"""Power-up cadono dai nemici distrutti."""

import pygame
from core.entity import Entity
from config import *

class PowerUp(Entity):
    def __init__(self, x: float, y: float, ptype: str):
        data = POWERUP_TYPES[ptype]
        super().__init__(x, y, 24, 24, data["color"])
        self.ptype = ptype
        self.symbol = data["symbol"]
        self.duration = data["duration"]
        self.vel = Vec2(0, POWERUP_SPEED)
        self.bob_timer = 0
        self.bob_amount = 3
    
    def update(self, dt: float):
        super().update(dt)
        self.bob_timer += dt * 3
        self.rect.y += int(self.bob_amount * pygame.math.sin(self.bob_timer))
        if self.is_off_screen():
            self.kill()
    
    def draw(self, surface: pygame.Surface):
        # Sfondo cerchio
        pygame.draw.circle(surface, self.color, self.rect.center, 14)
        pygame.draw.circle(surface, (20, 20, 30), self.rect.center, 12)
        # Simbolo testo
        font = pygame.font.Font(None, 28)
        txt = font.render(self.symbol, True, WHITE)
        surface.blit(txt, txt.get_rect(center=self.rect.center))

8. Particelle ed effetti: core/particle.py, core/effects.py

# core/particle.py
"""Sistema particelle semplice per esplosioni."""

import random
import pygame
from pygame.math import Vector2 as Vec2
from config import *

class Particle:
    def __init__(self, x: float, y: float, color: tuple):
        self.pos = Vec2(x, y)
        angle = random.uniform(0, 6.28)
        speed = random.uniform(50, 150)
        self.vel = Vec2(pygame.math.cos(angle), pygame.math.sin(angle)) * speed
        self.color = color
        self.life = PARTICLE_LIFETIME
        self.max_life = self.life
        self.size = random.randint(2, 5)
    
    def update(self, dt: float):
        self.pos += self.vel * dt
        self.vel *= 0.95  # Attrito
        self.life -= dt
    
    def draw(self, surface: pygame.Surface):
        if self.life <= 0:
            return
        alpha = int(255 * (self.life / self.max_life))
        color = (*self.color[:3], alpha)
        # Disegna su surface con per-pixel alpha
        temp = pygame.Surface((self.size*2, self.size*2), pygame.SRCALPHA)
        pygame.draw.circle(temp, color, (self.size, self.size), self.size)
        surface.blit(temp, (self.pos.x - self.size, self.pos.y - self.size))
    
    def is_dead(self) -> bool:
        return self.life <= 0

def create_explosion(x: float, y: float, color: tuple, count: int = PARTICLE_COUNT_EXPLOSION) -> list:
    return [Particle(x, y, color) for _ in range(count)]
# core/effects.py
"""Screen shake, flash, freeze frame."""

import random
import pygame

class EffectsManager:
    def __init__(self):
        self.shake_intensity = 0.0
        self.shake_duration = 0.0
        self.flash_color = None
        self.flash_duration = 0.0
        self.freeze_timer = 0.0
    
    def shake(self, intensity: float, duration: float):
        self.shake_intensity = max(self.shake_intensity, intensity)
        self.shake_duration = max(self.shake_duration, duration)
    
    def flash(self, color: tuple, duration: float):
        self.flash_color = color
        self.flash_duration = duration
    
    def freeze(self, duration: float):
        self.freeze_timer = duration
    
    def update(self, dt: float):
        if self.shake_duration > 0:
            self.shake_duration -= dt
            if self.shake_duration <= 0:
                self.shake_intensity = 0
        if self.flash_duration > 0:
            self.flash_duration -= dt
            if self.flash_duration <= 0:
                self.flash_color = None
        if self.freeze_timer > 0:
            self.freeze_timer -= dt
    
    def get_shake_offset(self) -> tuple:
        if self.shake_intensity > 0:
            return (random.uniform(-self.shake_intensity, self.shake_intensity),
                    random.uniform(-self.shake_intensity, self.shake_intensity))
        return (0, 0)
    
    def apply_flash(self, surface: pygame.Surface):
        if self.flash_color:
            overlay = pygame.Surface(surface.get_size(), pygame.SRCALPHA)
            alpha = int(100 * (self.flash_duration / 0.1))
            overlay.fill((*self.flash_color[:3], alpha))
            surface.blit(overlay, (0, 0), special_flags=pygame.BLEND_RGBA_ADD)
    
    def is_frozen(self) -> bool:
        return self.freeze_timer > 0

9. Sistema collisioni: systems/collision.py

# systems/collision.py
"""Sistema collisioni ottimizzato: broad phase + narrow phase."""

from core.entity import Entity
from core.projectile import Projectile
from core.enemy import Enemy
from core.powerup import PowerUp
from core.particle import create_explosion
from config import *

class CollisionSystem:
    def __init__(self):
        self.particles = []
    
    def check_collisions(self, player, projectiles: list, enemies: list, 
                         powerups: list) -> dict:
        """Restituisce eventi: {'player_hit': bool, 'enemies_killed': list, 'powerups_collected': list}"""
        events = {
            "player_hit": False,
            "enemies_killed": [],
            "powerups_collected": [],
            "score_gained": 0
        }
        
        # 1. Proiettili player vs Nemici
        for proj in projectiles[:]:
            if not proj.from_player or not proj.active:
                continue
            for enemy in enemies[:]:
                if not enemy.active:
                    continue
                if proj.collides_with(enemy):
                    proj.kill()
                    if enemy.take_damage():
                        enemy.active = False
                        events["enemies_killed"].append(enemy)
                        events["score_gained"] += enemy.score_value
                        # Particelle esplosione
                        self.particles.extend(create_explosion(
                            enemy.pos.x, enemy.pos.y, enemy.color))
                    else:
                        # Hit ma non ucciso
                        self.particles.extend(create_explosion(
                            proj.pos.x, proj.pos.y, YELLOW, 5))
                    break
        
        # 2. Proiettili nemici vs Player
        for proj in projectiles[:]:
            if proj.from_player or not proj.active:
                continue
            if player.active and proj.collides_with(player):
                proj.kill()
                if player.take_damage():
                    events["player_hit"] = True
                    self.particles.extend(create_explosion(
                        player.pos.x, player.pos.y, BLUE, 20))
                break
        
        # 3. Nemici vs Player (collisione corpo)
        for enemy in enemies:
            if enemy.active and player.active and enemy.collides_with(player):
                if player.take_damage():
                    events["player_hit"] = True
                    self.particles.extend(create_explosion(
                        player.pos.x, player.pos.y, BLUE, 20))
                enemy.kill()
                self.particles.extend(create_explosion(
                    enemy.pos.x, enemy.pos.y, enemy.color))
        
        # 4. Player vs Power-up
        for pup in powerups[:]:
            if pup.active and player.active and pup.collides_with(player):
                pup.kill()
                events["powerups_collected"].append(pup.ptype)
        
        return events
    
    def update_particles(self, dt: float):
        for p in self.particles[:]:
            p.update(dt)
            if p.is_dead():
                self.particles.remove(p)
    
    def draw_particles(self, surface: pygame.Surface):
        for p in self.particles:
            p.draw(surface)

10. Wave Manager: systems/spawn.py

# systems/spawn.py
"""Gestione ondate nemici, boss, power-up drop."""

import random
import pygame
from core.enemy import Enemy
from core.powerup import PowerUp
from config import *

class WaveManager:
    def __init__(self):
        self.wave = 0
        self.enemies_in_wave = 0
        self.enemies_spawned = 0
        self.spawn_timer = 0
        self.spawn_interval = 1.0
        self.wave_cleared = True
        self.boss_active = False
    
    def start_next_wave(self):
        self.wave += 1
        self.enemies_in_wave = WAVE_ENEMY_COUNT_BASE + (self.wave - 1) * WAVE_ENEMY_INCREASE
        self.enemies_spawned = 0
        self.wave_cleared = False
        self.boss_active = (self.wave % BOSS_WAVE_INTERVAL == 0)
        
        # Velocità aumenta per onda
        speed_mult = 1 + (self.wave - 1) * ENEMY_SPEED_INCREASE
        return speed_mult
    
    def update(self, dt: float, enemies: list, speed_mult: float) -> list:
        """Restituisce lista nuovi power-up droppati."""
        new_powerups = []
        
        if self.wave_cleared:
            return new_powerups
        
        self.spawn_timer -= dt
        
        if self.enemies_spawned < self.enemies_in_wave and self.spawn_timer <= 0:
            self._spawn_enemy(enemies, speed_mult)
            self.enemies_spawned += 1
            self.spawn_timer = self.spawn_interval
        
        # Controlla fine onda
        active_enemies = [e for e in enemies if e.active]
        if self.enemies_spawned >= self.enemies_in_wave and not active_enemies:
            self.wave_cleared = True
        
        return new_powerups
    
    def _spawn_enemy(self, enemies: list, speed_mult: float):
        # Scegli tipo basato su onda
        if self.boss_active:
            etype = "tank"  # Boss = tank potenziato
        elif self.wave >= 3:
            etype = random.choices(
                ["basic", "fast", "tank", "shooter"],
                weights=[40, 25, 20, 15]
            )[0]
        else:
            etype = random.choices(["basic", "fast"], weights=[70, 30])[0]
        
        x = random.randint(40, SCREEN_WIDTH - 40)
        y = -50
        enemy = Enemy(x, y, etype)
        enemy.vel.y *= speed_mult
        
        if self.boss_active:
            enemy.hp = enemy.max_hp = 10
            enemy.score_value = 200
            enemy.size = Vec2(60, 55)
            enemy.color = PURPLE
        
        enemies.append(enemy)
    
    def on_enemy_killed(self, enemy: Enemy) -> list:
        """Chiamato quando nemico muore, restituisce power-up droppati."""
        drops = []
        if random.random() < POWERUP_SPAWN_CHANCE:
            ptype = random.choice(list(POWERUP_TYPES.keys()))
            drops.append(PowerUp(enemy.pos.x, enemy.pos.y, ptype))
        return drops

11. Score & High-score: systems/score.py

# systems/score.py
"""Punteggio, high-score persistente (JSON)."""

import json
import os
from config import *

class ScoreSystem:
    def __init__(self):
        self.score = 0
        self.highscores = self._load()
    
    def _load(self) -> list:
        if os.path.exists(HIGHSCORE_FILE):
            try:
                with open(HIGHSCORE_FILE, "r") as f:
                    return json.load(f)
            except Exception:
                return []
        return []
    
    def save(self):
        with open(HIGHSCORE_FILE, "w") as f:
            json.dump(self.highscores, f)
    
    def add_score(self, points: int):
        self.score += points
    
    def reset(self):
        self.score = 0
    
    def check_highscore(self) -> bool:
        """True se score entra in top 10."""
        if len(self.highscores) < MAX_HIGHSCORES:
            return True
        return self.score > min(hs["score"] for hs in self.highscores)
    
    def add_highscore(self, name: str):
        entry = {"name": name[:12], "score": self.score, "wave": 0}
        self.highscores.append(entry)
        self.highscores.sort(key=lambda x: x["score"], reverse=True)
        self.highscores = self.highscores[:MAX_HIGHSCORES]
        self.save()
    
    def get_highscores(self) -> list:
        return self.highscores

12. UI: Menu, HUD, Game Over — ui/menu.py, ui/hud.py

# ui/button.py
"""Pulsante riutilizzabile per menu."""

import pygame
from config import *

class Button:
    def __init__(self, x: int, y: int, w: int, h: int, text: str, 
                 font: pygame.font.Font, action=None):
        self.rect = pygame.Rect(x, y, w, h)
        self.text = text
        self.font = font
        self.action = action
        self.hovered = False
        self.colors = {
            "bg": (60, 60, 80),
            "bg_hover": (100, 100, 140),
            "border": (180, 180, 200),
            "text": WHITE,
        }
    
    def update(self, mouse_pos: tuple):
        self.hovered = self.rect.collidepoint(mouse_pos)
    
    def handle_event(self, event: pygame.event.Event) -> bool:
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            if self.hovered and self.action:
                self.action()
                return True
        return False
    
    def draw(self, surface: pygame.Surface):
        color = self.colors["bg_hover"] if self.hovered else self.colors["bg"]
        pygame.draw.rect(surface, color, self.rect, border_radius=8)
        pygame.draw.rect(surface, self.colors["border"], self.rect, 2, border_radius=8)
        txt = self.font.render(self.text, True, self.colors["text"])
        surface.blit(txt, txt.get_rect(center=self.rect.center))
# ui/menu.py
"""Menu principale, pausa, game over."""

import pygame
from ui.button import Button
from config import *

class Menu:
    def __init__(self, game):
        self.game = game
        self.font_title = pygame.font.Font(None, 64)
        self.font_btn = pygame.font.Font(None, 36)
        self.font_small = pygame.font.Font(None, 24)
        self.buttons = []
        self._build_main()
    
    def _build_main(self):
        self.buttons.clear()
        cx = SCREEN_WIDTH // 2
        self.buttons.append(Button(cx-100, 280, 200, 50, "GIOCA", self.font_btn,
                                   lambda: self.game.change_state(GameState.PLAYING)))
        self.buttons.append(Button(cx-100, 350, 200, 50, "HIGH SCORE", self.font_btn,
                                   lambda: self.game.show_highscores()))
        self.buttons.append(Button(cx-100, 420, 200, 50, "ESCI", self.font_btn,
                                   self.game.quit))
    
    def _build_pause(self):
        self.buttons.clear()
        cx = SCREEN_WIDTH // 2
        self.buttons.append(Button(cx-100, 280, 200, 50, "RIPRENDI", self.font_btn,
                                   lambda: self.game.change_state(GameState.PLAYING)))
        self.buttons.append(Button(cx-100, 350, 200, 50, "MENU", self.font_btn,
                                   lambda: self.game.change_state(GameState.MENU)))
    
    def _build_game_over(self):
        self.buttons.clear()
        cx = SCREEN_WIDTH // 2
        self.buttons.append(Button(cx-100, 380, 200, 50, "RIGIOCA", self.font_btn,
                                   lambda: self.game.restart()))
        self.buttons.append(Button(cx-100, 450, 200, 50, "MENU", self.font_btn,
                                   lambda: self.game.change_state(GameState.MENU)))
    
    def set_state(self, state: str):
        if state == GameState.MENU:
            self._build_main()
        elif state == GameState.PAUSED:
            self._build_pause()
        elif state in (GameState.GAME_OVER, GameState.VICTORY):
            self._build_game_over()
    
    def update(self, mouse_pos: tuple):
        for btn in self.buttons:
            btn.update(mouse_pos)
    
    def handle_event(self, event: pygame.event.Event):
        for btn in self.buttons:
            btn.handle_event(event)
    
    def draw(self, surface: pygame.Surface, state: str, score: int = 0, wave: int = 0):
        # Overlay scuro
        overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SRCALPHA)
        overlay.fill((0, 0, 0, 180))
        surface.blit(overlay, (0, 0))
        
        if state == GameState.MENU:
            title = self.font_title.render("STAR DEFENDER", True, CYAN)
            surface.blit(title, title.get_rect(center=(SCREEN_WIDTH//2, 160)))
            subtitle = self.font_small.render("Freccie/WASD: muovi  |  SPAZIO: spara  |  ESC: pausa", True, (150,150,150))
            surface.blit(subtitle, subtitle.get_rect(center=(SCREEN_WIDTH//2, 220)))
        
        elif state == GameState.PAUSED:
            title = self.font_title.render("PAUSA", True, YELLOW)
            surface.blit(title, title.get_rect(center=(SCREEN_WIDTH//2, 180)))
        
        elif state in (GameState.GAME_OVER, GameState.VICTORY):
            if state == GameState.GAME_OVER:
                title = self.font_title.render("GAME OVER", True, RED)
            else:
                title = self.font_title.render("VITTORIA!", True, GREEN)
            surface.blit(title, title.get_rect(center=(SCREEN_WIDTH//2, 180)))
            score_txt = self.font_btn.render(f"Punteggio: {score}  |  Onda: {wave}", True, WHITE)
            surface.blit(score_txt, score_txt.get_rect(center=(SCREEN_WIDTH//2, 260)))
            if self.game.score_system.check_highscore():
                hs_txt = self.font_small.render("NUOVO HIGH SCORE! Inserisci nome alla fine.", True, YELLOW)
                surface.blit(hs_txt, hs_txt.get_rect(center=(SCREEN_WIDTH//2, 310)))
        
        for btn in self.buttons:
            btn.draw(surface)
# ui/hud.py
"""HUD in gioco: vite, score, wave, power-up attivi."""

import pygame
from config import *

class HUD:
    def __init__(self):
        self.font = pygame.font.Font(None, 28)
        self.font_small = pygame.font.Font(None, 20)
    
    def draw(self, surface: pygame.Surface, player, wave: int, score: int):
        # Vite (cuori)
        for i in range(player.lives):
            x = 15 + i * 30
            pygame.draw.polygon(surface, RED, [
                (x+12, y:=15), (x+24, y+10), (x+12, y+24), (x, y+10)
            ])
        
        # Score
        score_surf = self.font.render(f"SCORE: {score}", True, WHITE)
        surface.blit(score_surf, (SCREEN_WIDTH - score_surf.get_width() - 15, 10))
        
        # Wave
        wave_surf = self.font.render(f"WAVE: {wave}", True, CYAN)
        surface.blit(wave_surf, (SCREEN_WIDTH//2 - wave_surf.get_width()//2, 10))
        
        # Power-up attivi
        y_off = 50
        if player.rapid_fire:
            txt = self.font_small.render(f"⚡ RAPID FIRE: {player.rapid_fire_timer:.1f}s", True, GREEN)
            surface.blit(txt, (15, y_off))
            y_off += 22
        if player.shield:
            txt = self.font_small.render(f"🛡 SHIELD: {player.shield_timer:.1f}s", True, BLUE)
            surface.blit(txt, (15, y_off))
            y_off += 22
        if player.spread_shot:
            txt = self.font_small.render(f"✦ SPREAD: {player.spread_timer:.1f}s", True, PURPLE)
            surface.blit(txt, (15, y_off))

13. Game Manager: main.py (il cuore)

# main.py
"""Star Defender - Entry point e Game Manager."""

import pygame
import sys
from config import *
from core.player import Player
from core.enemy import Enemy
from core.projectile import Projectile
from core.powerup import PowerUp
from core.particle import create_explosion
from core.effects import EffectsManager
from systems.collision import CollisionSystem
from systems.spawn import WaveManager
from systems.score import ScoreSystem
from ui.menu import Menu
from ui.hud import HUD
from GameState import GameState

class Game:
    def __init__(self):
        pygame.init()
        pygame.mixer.init()
        self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
        pygame.display.set_caption(TITLE)
        self.clock = pygame.time.Clock()
        self.running = True
        self.dt = 0
        
        self.state = GameState.MENU
        self.score_system = ScoreSystem()
        self.collision = CollisionSystem()
        self.wave_mgr = WaveManager()
        self.effects = EffectsManager()
        self.menu = Menu(self)
        self.hud = HUD()
        
        self._reset_game()
    
    def _reset_game(self):
        self.player = Player(SCREEN_WIDTH//2, SCREEN_HEIGHT - 80)
        self.enemies = []
        self.projectiles = []
        self.powerups = []
        self.score_system.reset()
        self.wave_mgr = WaveManager()
        self.speed_mult = self.wave_mgr.start_next_wave()
    
    def change_state(self, new_state: str):
        self.state = new_state
        self.menu.set_state(new_state)
        if new_state == GameState.PLAYING:
            pygame.mouse.set_visible(False)
        else:
            pygame.mouse.set_visible(True)
    
    def show_highscores(self):
        # TODO: implementa schermata high-score
        pass
    
    def restart(self):
        self._reset_game()
        self.change_state(GameState.PLAYING)
    
    def quit(self):
        self.running = False
    
    def handle_events(self):
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                self.running = False
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_ESCAPE:
                    if self.state == GameState.PLAYING:
                        self.change_state(GameState.PAUSED)
                    elif self.state == GameState.PAUSED:
                        self.change_state(GameState.PLAYING)
                    elif self.state in (GameState.GAME_OVER, GameState.VICTORY):
                        self.change_state(GameState.MENU)
                elif event.key == pygame.K_F11:
                    pygame.display.toggle_fullscreen()
            
            self.menu.handle_event(event)
    
    def update(self):
        if self.state != GameState.PLAYING:
            self.menu.update(pygame.mouse.get_pos())
            return
        
        # Freeze frame
        if self.effects.is_frozen():
            return
        
        keys = pygame.key.get_pressed()
        
        # Update entities
        self.player.update(self.dt, keys, self.projectiles)
        
        for enemy in self.enemies:
            enemy.update(self.dt, self.projectiles, self.player.pos)
        
        for proj in self.projectiles[:]:
            proj.update(self.dt)
            if not proj.active:
                self.projectiles.remove(proj)
        
        for pup in self.powerups[:]:
            pup.update(self.dt)
            if not pup.active:
                self.powerups.remove(pup)
        
        # Wave spawn
        new_powerups = self.wave_mgr.update(self.dt, self.enemies, self.speed_mult)
        self.powerups.extend(new_powerups)
        
        # Collisioni
        events = self.collision.check_collisions(
            self.player, self.projectiles, self.enemies, self.powerups)
        
        if events["player_hit"]:
            self.effects.shake(8, 0.3)
            self.effects.flash(RED, 0.1)
            self.effects.freeze(0.05)
            if self.player.lives <= 0:
                self._game_over()
        
        for enemy in events["enemies_killed"]:
            self.effects.shake(4, 0.15)
            self.effects.flash(enemy.color, 0.05)
            drops = self.wave_mgr.on_enemy_killed(enemy)
            self.powerups.extend(drops)
        
        for ptype in events["powerups_collected"]:
            self.player.apply_powerup(ptype)
            self.effects.flash(GREEN, 0.1)
        
        self.score_system.add_score(events["score_gained"])
        
        # Particelle
        self.collision.update_particles(self.dt)
        
        # Effetti
        self.effects.update(self.dt)
        
        # Prossima onda
        if self.wave_mgr.wave_cleared and self.state == GameState.PLAYING:
            if self.wave_mgr.wave >= 15:
                self._victory()
            else:
                self.speed_mult = self.wave_mgr.start_next_wave()
    
    def _game_over(self):
        self.change_state(GameState.GAME_OVER)
    
    def _victory(self):
        self.change_state(GameState.VICTORY)
    
    def draw(self):
        self.screen.fill(BG_COLOR)
        
        if self.state == GameState.PLAYING or self.state == GameState.PAUSED:
            # Entità
            for enemy in self.enemies:
                if enemy.active:
                    enemy.draw(self.screen)
            for proj in self.projectiles:
                if proj.active:
                    proj.draw(self.screen)
            for pup in self.powerups:
                if pup.active:
                    pup.draw(self.screen)
            self.player.draw(self.screen)
            
            # Particelle
            self.collision.draw_particles(self.screen)
            
            # HUD
            self.hud.draw(self.screen, self.player, self.wave_mgr.wave, self.score_system.score)
            
            # Pause overlay
            if self.state == GameState.PAUSED:
                self.menu.draw(self.screen, self.state)
        
        elif self.state in (GameState.MENU, GameState.GAME_OVER, GameState.VICTORY):
            self.menu.draw(self.screen, self.state, 
                          self.score_system.score, self.wave_mgr.wave)
        
        # Effetti screen shake/flash
        shake_x, shake_y = self.effects.get_shake_offset()
        self.effects.apply_flash(self.screen)
        
        # Applica shake blittando su surface offset
        if shake_x != 0 or shake_y != 0:
            temp = self.screen.copy()
            self.screen.fill(BG_COLOR)
            self.screen.blit(temp, (shake_x, shake_y))
        
        pygame.display.flip()
    
    def run(self):
        while self.running:
            self.dt = self.clock.tick(FPS) / 1000.0
            self.handle_events()
            self.update()
            self.draw()
        pygame.quit()
        sys.exit()

if __name__ == "__main__":
    game = Game()
    game.run()

14. Packaging: crea eseguibile condivisibile

PyInstaller

pip install pyinstaller

# Windows: .exe singolo file
pyinstaller --onefile --windowed --icon=assets/images/icon.ico \
            --add-data "assets;assets" \
            --name "StarDefender" main.py

# macOS: .app bundle
pyinstaller --onefile --windowed --icon=assets/images/icon.icns \
            --add-data "assets:assets" \
            --name "StarDefender" main.py

# Linux: binario
pyinstaller --onefile --windowed --add-data "assets:assets" main.py

Struttura dist/

dist/
└── StarDefender.exe      # Windows
    o
dist/
└── StarDefender.app      # macOS (cartella)
    └── Contents/MacOS/StarDefender

Spec file per controllo avanzato (StarDefender.spec)

# PyInstaller spec file
block_cipher = None

a = Analysis(
    ['main.py'],
    pathex=[],
    binaries=[],
    datas=[('assets', 'assets')],
    hiddenimports=['pygame', 'pygame._view'],
    hookspath=[],
    hooksconfig={},
    runtime_hooks=[],
    excludes=['tkinter', 'matplotlib', 'numpy', 'pandas'],
    win_no_prefer_redirects=False,
    win_private_assemblies=False,
    cipher=block_cipher,
    noarchive=False,
)

pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)

exe = EXE(
    pyz, a.scripts, a.binaries, a.zipfiles, a.datas,
    [],
    name='StarDefender',
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=True,
    upx_exclude=[],
    runtime_tmpdir=None,
    console=False,
    disable_windowed_traceback=False,
    argv_emulation=False,
    target_arch=None,
    codesign_identity=None,
    entitlements_file=None,
    icon='assets/images/icon.ico'
)
pyinstaller StarDefender.spec

15. Checklist finale progetto

| ✅ Completato | Componente | | --------------- | ------------ | | [ ] GDD scritto | Design document | | [ ] Config centralizzato | config.py | | [ ] Entity base | core/entity.py | | [ ] Player completo | Movimento, sparo, power-up, invuln | | [ ] Nemici vari | Basic, fast, tank, shooter, boss | | [ ] Proiettili | Player/enemy, direzioni | | [ ] Power-up | 4 tipi, durate, visual | | [ ] Particelle | Esplosioni, fade | | [ ] Effetti | Screen shake, flash, freeze | | [ ] Collisioni | Broad/narrow phase, eventi | | [ ] Wave Manager | Spawn, difficoltà, boss | | [ ] Score System | Punteggio, high-score JSON | | [ ] Menu/HUD | Navigazione, info in-game | | [ ] Game Manager | State machine, loop principale | | [ ] Build eseguibile | PyInstaller .exe/.app |


16. Prossimi passi (oltre il libro)

  1. Asset reali: Sostituisci forme con sprite PNG (Kenney.nl, OpenGameArt.org)
  2. Audio: Aggiungi systems/audio.py con musica dinamica, SFX
  3. Salvataggio: savegame.json per continuità
  4. Controller: Supporto gamepad (pygame.joystick)
  5. Particelle GPU: pygame.gpu o shader per migliaia particelle
  6. Multiplayer locale: Due player, split screen
  7. Level editor: Tiled map editor → JSON → parser Python
  8. Pubblica: itch.io, GitHub Releases, Microsoft Store (MSIX)

Conclusione della serie

Hai completato "Divento Programmatore: Python e i Giochi" (Cervello Elettronico Vol.2)!

| Capitolo | Competenza chiave | | ---------- | ------------------- | | 1 | Installazione, REPL, Hello World, codice = istruzioni | | 2 | Variabili, tipi, input, f-string, naming | | 3 | Boolean logic, if/elif/else, while, for, range | | 4 | Funzioni, parametri, return, scope, docstring, DRY | | 5 | Liste, dizionari, nesting, loop, comprensioni | | 6 | Moduli, stdlib (random, time, math), pip, package | | 7 | Pygame: finestra, event loop, draw, input, clock | | 8 | Progetto completo: architettura, sistemi, packaging |

Hai ora gli strumenti per creare qualsiasi gioco 2D. La programmazione è pratica: scrivi codice, rompi cose, ripara, ripeti.

"Il miglior modo per imparare a programmare è programmare." — Qualcuno di saggio


Risorse utili


Fine della serie "Divento Programmatore: Python e i Giochi" (Cervello Elettronico Vol.2). Grazie per aver seguito il percorso! Condividi i tuoi giochi, continua a imparare, divertiti a programmare. 🚀

Per approfondire

Condividi LinkedIn Facebook X WhatsApp
Continua a leggere

Tutti gli articoli del blog

Strumenti gratuiti