Skip to content

Referência da API

Gerada automaticamente a partir das docstrings de src/ (R31.4). Cada seção é um módulo do jogo — a documentação aqui é sempre a mesma coisa que está no código.

src.assets

src.assets

Resolve o caminho de um asset bundlado, somente-leitura (R21.2).

Diferente de storage.save_dir() (que resolve onde GRAVAR e evita sys._MEIPASS de proposito, ja que esse diretorio e apagado ao fechar o processo), aqui o caso e o oposto: o PyInstaller onefile EXTRAI os dados empacotados (via datas= no .spec) para sys._MEIPASS a cada execucao — e exatamente onde um asset builtin deve ser lido no executavel empacotado.

asset_path(filename)

Resolve o caminho absoluto de um asset em assets/filename.

Source code in src/assets.py
16
17
18
19
20
21
22
def asset_path(filename: str) -> Path:
    """Resolve o caminho absoluto de um asset em `assets/filename`."""
    if storage.is_frozen():
        base = Path(getattr(sys, "_MEIPASS", "."))
    else:
        base = Path(__file__).resolve().parent.parent  # raiz do projeto (fonte ou apk do p4a)
    return base / "assets" / filename

src.bands

src.bands

Faixas laterais: corte transversal do subsolo do bioma (R25.2, R24.4).

Quando a tela e mais larga que a area jogavel, cada lateral recebe um corte transversal do terreno, opaco e ocupando a altura inteira do canvas. A area jogavel passa a ser lida como um poco vertical cortado na terra, a unica parte por onde se enxerga o ceu — a estetica de mineshaft do Minecraft.

A opacidade nao e enfeite, e o mecanismo de R24.4: as faixas sao desenhadas DEPOIS das colunas, entao uma coluna que ainda nao entrou na area jogavel fica escondida atras da terra. Combinado com o spawn em play.right (R24.3), o jogador enxerga o obstaculo no mesmo instante em que enxergaria numa tela 2:3 — a oclusao e o que impede uma tela mais larga de virar vantagem.

Ver specs/v3/design.md secao 32.5.

DEEP_BLOCK = {'overworld': 'stone', 'cave': 'cobblestone', 'nether': 'obsidian'} module-attribute

Camada profunda de cada bioma: Overworld grama -> terra -> pedra, Cave pedra -> pedregulho, Nether netherrack -> obsidiana. Chaveado pelo id do bioma, no mesmo padrao de decor._LAYERS, para que a decoracao nao vire campo de Biome.

ORE_VEIN_PERIOD = 90 module-attribute

Distancia horizontal entre veios de minerio dentro da faixa.

TOPSOIL_ROWS = 3 module-attribute

Fileiras do bloco principal entre a linha do chao e a camada profunda.

SideBands

Desenha as duas faixas laterais, cada uma como uma unica imagem por bioma.

O conteudo so muda quando o canvas ou o bioma mudam, entao cada parede e construida uma vez e guardada com o renderizador, que a chaveia por bioma, lado e tamanho — a mesma disciplina dos gradientes de ceu.

Source code in src/bands.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
class SideBands:
    """Desenha as duas faixas laterais, cada uma como uma unica imagem por bioma.

    O conteudo so muda quando o canvas ou o bioma mudam, entao cada parede e
    construida uma vez e guardada com o renderizador, que a chaveia por bioma, lado e
    tamanho — a mesma disciplina dos gradientes de ceu.
    """

    def draw(self, renderer: render.Renderer, textures: dict[str, pygame.Surface], biome: Biome) -> None:
        """Cobre as laterais do canvas. Sem sobra horizontal (tela 2:3), nao faz nada."""
        vp = config.viewport()
        left_rect, right_rect = vp.left_band, vp.right_band
        if left_rect.width == 0 and right_rect.width == 0:
            return

        # semente diferente por lado: duas paredes identicas em espelho entregariam a
        # simetria de graca e denunciariam a repeticao.
        for rect, seed in ((left_rect, 0), (right_rect, 1000)):
            if not rect.width:
                continue
            image = self._band(renderer, textures, biome, seed)
            renderer.draw(image, rect.topleft, pygame.Rect(0, 0, rect.width, rect.height))

    def _band(
        self, renderer: render.Renderer, textures: dict[str, pygame.Surface], biome: Biome, seed: int
    ) -> render.Image:
        vp = config.viewport()
        ground_y = config.ground_y()
        width = max(vp.left_band.width, vp.right_band.width)
        return renderer.image(
            ("band", biome.id, seed, width, vp.height, ground_y),
            lambda: _build(width, vp.height, ground_y, biome, textures, seed),
        )
draw(renderer, textures, biome)

Cobre as laterais do canvas. Sem sobra horizontal (tela 2:3), nao faz nada.

Source code in src/bands.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def draw(self, renderer: render.Renderer, textures: dict[str, pygame.Surface], biome: Biome) -> None:
    """Cobre as laterais do canvas. Sem sobra horizontal (tela 2:3), nao faz nada."""
    vp = config.viewport()
    left_rect, right_rect = vp.left_band, vp.right_band
    if left_rect.width == 0 and right_rect.width == 0:
        return

    # semente diferente por lado: duas paredes identicas em espelho entregariam a
    # simetria de graca e denunciariam a repeticao.
    for rect, seed in ((left_rect, 0), (right_rect, 1000)):
        if not rect.width:
            continue
        image = self._band(renderer, textures, biome, seed)
        renderer.draw(image, rect.topleft, pygame.Rect(0, 0, rect.width, rect.height))

src.biome

src.biome

Definicoes de bioma e gerenciador de transicao (R5, R2.5, R7.3).

Biome dataclass

Parametros de um bioma: threshold de ativacao, velocidade, texturas e ceu.

Source code in src/biome.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@dataclass(frozen=True)
class Biome:
    """Parametros de um bioma: threshold de ativacao, velocidade, texturas e ceu."""

    id: str
    name: str
    threshold: int
    speed: float
    gap_size: int
    sky_top: tuple[int, int, int]
    sky_bottom: tuple[int, int, int]
    block_main: str
    block_edge: str
    decor: str

BiomeManager

Acompanha o bioma ativo e as transicoes de fade/banner conforme o score.

Source code in src/biome.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
class BiomeManager:
    """Acompanha o bioma ativo e as transicoes de fade/banner conforme o score."""

    def __init__(self) -> None:
        """Inicia no primeiro bioma (Overworld), sem fade nem banner em curso."""
        self.current = BIOMES[0]
        self.previous = BIOMES[0]
        self.fade_timer = 0
        self.banner_timer = 0

    def update(self, score: int) -> bool:
        """Detecta cruzamento de threshold (R5.1) e ativa fade + banner (R5.4).

        Retorna True se houve transicao.
        """
        target = _biome_for_score(score)
        if target.id != self.current.id:
            self.previous = self.current
            self.current = target
            self.fade_timer = FADE_FRAMES
            self.banner_timer = BANNER_FRAMES
            return True
        self.fade_timer = max(0, self.fade_timer - 1)
        self.banner_timer = max(0, self.banner_timer - 1)
        return False

    def _gradient(self, renderer: render.Renderer, biome: Biome) -> render.Image:
        """Gradiente de ceu do bioma, construido uma vez por tamanho de canvas (R14.3).

        A chave inclui o tamanho porque o canvas real so e conhecido em runtime e pode
        mudar num redimensionamento (R23.6).
        """
        width, height = renderer.size
        return renderer.image(
            ("sky", biome.id, width, height),
            lambda: _make_gradient(biome.sky_top, biome.sky_bottom, width, height),
        )

    def draw_background(self, renderer: render.Renderer) -> None:
        """Desenha o ceu do bioma atual, cruzando com o anterior durante o fade."""
        current_grad = self._gradient(renderer, self.current)
        if self.fade_timer > 0:
            prev_grad = self._gradient(renderer, self.previous)
            prev_grad.alpha = 255
            renderer.draw(prev_grad, (0, 0))
            current_grad.alpha = round(255 * (1 - self.fade_timer / FADE_FRAMES))
            renderer.draw(current_grad, (0, 0))
        else:
            current_grad.alpha = 255
            renderer.draw(current_grad, (0, 0))

    def draw_banner(self, renderer: render.Renderer) -> None:
        """Desenha o nome do bioma no topo da area jogavel enquanto o banner dura."""
        if self.banner_timer > 0:
            play = config.play()
            ui.draw_text(renderer, self.current.name.upper(), (play.centerx, play.top + 110), base_size=16)
__init__()

Inicia no primeiro bioma (Overworld), sem fade nem banner em curso.

Source code in src/biome.py
 95
 96
 97
 98
 99
100
def __init__(self) -> None:
    """Inicia no primeiro bioma (Overworld), sem fade nem banner em curso."""
    self.current = BIOMES[0]
    self.previous = BIOMES[0]
    self.fade_timer = 0
    self.banner_timer = 0
draw_background(renderer)

Desenha o ceu do bioma atual, cruzando com o anterior durante o fade.

Source code in src/biome.py
130
131
132
133
134
135
136
137
138
139
140
141
def draw_background(self, renderer: render.Renderer) -> None:
    """Desenha o ceu do bioma atual, cruzando com o anterior durante o fade."""
    current_grad = self._gradient(renderer, self.current)
    if self.fade_timer > 0:
        prev_grad = self._gradient(renderer, self.previous)
        prev_grad.alpha = 255
        renderer.draw(prev_grad, (0, 0))
        current_grad.alpha = round(255 * (1 - self.fade_timer / FADE_FRAMES))
        renderer.draw(current_grad, (0, 0))
    else:
        current_grad.alpha = 255
        renderer.draw(current_grad, (0, 0))
draw_banner(renderer)

Desenha o nome do bioma no topo da area jogavel enquanto o banner dura.

Source code in src/biome.py
143
144
145
146
147
def draw_banner(self, renderer: render.Renderer) -> None:
    """Desenha o nome do bioma no topo da area jogavel enquanto o banner dura."""
    if self.banner_timer > 0:
        play = config.play()
        ui.draw_text(renderer, self.current.name.upper(), (play.centerx, play.top + 110), base_size=16)
update(score)

Detecta cruzamento de threshold (R5.1) e ativa fade + banner (R5.4).

Retorna True se houve transicao.

Source code in src/biome.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def update(self, score: int) -> bool:
    """Detecta cruzamento de threshold (R5.1) e ativa fade + banner (R5.4).

    Retorna True se houve transicao.
    """
    target = _biome_for_score(score)
    if target.id != self.current.id:
        self.previous = self.current
        self.current = target
        self.fade_timer = FADE_FRAMES
        self.banner_timer = BANNER_FRAMES
        return True
    self.fade_timer = max(0, self.fade_timer - 1)
    self.banner_timer = max(0, self.banner_timer - 1)
    return False

src.bird

src.bird

Classe Bird: fisica e animacao do passaro (R1).

ANGLES = tuple(range(MAX_ANGLE_DOWN, MAX_ANGLE_UP + 1, ANGLE_FALL_STEP)) module-attribute

Os 31 angulos que a abelha pode assumir, e a grade a que qualquer angulo e preso.

Nao e uma escolha de renderizacao: a fisica ja produzia so estes valores. flap fixa o angulo em MAX_ANGLE_UP e a queda o decrementa de ANGLE_FALL_STEP em ANGLE_FALL_STEP ate MAX_ANGLE_DOWN, entao o conjunto alcancavel e exatamente esta progressao. Torna-la explicita e o que permite pre-computar as 31 x 2 = 62 texturas em vez de descobri-las uma a uma durante o jogo (R27.2).

Bird

Fisica e animacao do passaro: gravidade, flap, rotacao e hitbox.

Sem __dict__ por instancia: e uma classe de dados pequena e estavel, o caso em que __slots__ rende — menos memoria e acesso a atributo mais direto (R27.3).

Source code in src/bird.py
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
class Bird:
    """Fisica e animacao do passaro: gravidade, flap, rotacao e hitbox.

    Sem `__dict__` por instancia: e uma classe de dados pequena e estavel, o caso
    em que `__slots__` rende — menos memoria e acesso a atributo mais direto (R27.3).
    """

    __slots__ = ("_frame_timer", "_idle_timer", "angle", "base_y", "frame", "pos", "rect", "size", "vel_y")

    def __init__(self, x: float, y: float) -> None:
        """Cria o passaro na posicao `(x, y)`, parado, com hitbox sincronizada."""
        self.pos = pygame.Vector2(x, y)
        self.base_y = y
        self.vel_y = 0.0
        self.angle = 0.0
        self.frame = 0
        self.size = BLOCK
        self._frame_timer = 0
        self._idle_timer = 0
        hitbox_size = round(self.size * HITBOX_SCALE)
        self.rect = pygame.Rect(0, 0, hitbox_size, hitbox_size)
        """Hitbox reduzida (~85% do sprite) centralizada (R3.5).

        Atributo persistente, e nao `property`: `Game._collision_texture` a le uma vez
        por coluna, dentro do laco, e cada leitura construia dois `pygame.Rect` novos —
        dezenas de objetos descartaveis por frame para uma geometria que muda uma vez
        por passo de simulacao (R27.3). Quem mexe em `pos` fora de `update`/`update_idle`
        precisa chamar `sync_rect`."""
        self.sync_rect()

    def flap(self) -> None:
        """Aplica o impulso de subida e vira o sprite para cima."""
        self.vel_y = FLAP_IMPULSE
        self.angle = MAX_ANGLE_UP

    def update(self) -> None:
        """Avanca um passo de fisica: gravidade, posicao, rotacao e clamp no teto."""
        self.vel_y = min(self.vel_y + GRAVITY, MAX_FALL_SPEED)
        self.pos.y += self.vel_y

        # o teto fica na borda da AREA JOGAVEL, nao na do canvas: a faixa de ceu e
        # decorativa e nao pode dar espaco extra para voar (R24.5).
        ceiling = config.play().top
        if self.pos.y < ceiling:
            self.pos.y = ceiling
            self.vel_y = 0

        if self.vel_y < 0:
            self.angle = MAX_ANGLE_UP
        else:
            self.angle = max(MAX_ANGLE_DOWN, self.angle - ANGLE_FALL_STEP)

        self._advance_wing_frame()
        self.sync_rect()

    def update_idle(self) -> None:
        """Flutuacao senoidal no estado PRONTO, sem gravidade (R6.1)."""
        self._idle_timer += 1
        self.pos.y = self.base_y + math.sin(self._idle_timer * IDLE_BOB_SPEED) * IDLE_BOB_AMPLITUDE
        self.angle = 0
        self._advance_wing_frame()
        self.sync_rect()

    def _advance_wing_frame(self) -> None:
        self._frame_timer += 1
        if self._frame_timer >= WING_FRAME_INTERVAL:
            self._frame_timer = 0
            self.frame = 1 - self.frame

    def sync_rect(self) -> None:
        """Recentra a hitbox persistente na posicao atual.

        `centerx`/`centery` em vez de `center = (x, y)`: a forma com tupla constroi um
        objeto por chamada, e a chamada acontece uma vez por passo de simulacao.

        A conta e a mesma da `property` que existia aqui — `round(pos)` primeiro, meia
        largura depois — e nao `round(pos + size / 2)`: para um `size` impar as duas
        divergem, e a hitbox nao pode mudar de lugar por causa de uma otimizacao.
        """
        self.rect.centerx = round(self.pos.x) + self.size // 2
        self.rect.centery = round(self.pos.y) + self.size // 2

    def draw(self, renderer: render.Renderer, textures: dict[str, pygame.Surface]) -> None:
        """Consulta a textura do (frame de asa, angulo) atual e desenha (R27.2).

        Nao ha rotacao aqui: as 62 combinacoes ja foram rotacionadas por
        `precompute_sprites` na inicializacao, e `quantize` garante que a consulta
        sempre caia numa delas. `pygame.transform.rotate` aloca uma superficie nova a
        cada chamada e reamostra o sprite inteiro — barato uma vez, caro sessenta
        vezes por segundo (R27.3).
        """
        image = _sprite(renderer, textures, self.frame, quantize(self.angle))
        width, height = image.size
        center_x = self.pos.x + self.size / 2
        center_y = self.pos.y + self.size / 2
        renderer.draw(image, (round(center_x - width / 2), round(center_y - height / 2)))
rect = pygame.Rect(0, 0, hitbox_size, hitbox_size) instance-attribute

Hitbox reduzida (~85% do sprite) centralizada (R3.5).

Atributo persistente, e nao property: Game._collision_texture a le uma vez por coluna, dentro do laco, e cada leitura construia dois pygame.Rect novos — dezenas de objetos descartaveis por frame para uma geometria que muda uma vez por passo de simulacao (R27.3). Quem mexe em pos fora de update/update_idle precisa chamar sync_rect.

__init__(x, y)

Cria o passaro na posicao (x, y), parado, com hitbox sincronizada.

Source code in src/bird.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def __init__(self, x: float, y: float) -> None:
    """Cria o passaro na posicao `(x, y)`, parado, com hitbox sincronizada."""
    self.pos = pygame.Vector2(x, y)
    self.base_y = y
    self.vel_y = 0.0
    self.angle = 0.0
    self.frame = 0
    self.size = BLOCK
    self._frame_timer = 0
    self._idle_timer = 0
    hitbox_size = round(self.size * HITBOX_SCALE)
    self.rect = pygame.Rect(0, 0, hitbox_size, hitbox_size)
    """Hitbox reduzida (~85% do sprite) centralizada (R3.5).

    Atributo persistente, e nao `property`: `Game._collision_texture` a le uma vez
    por coluna, dentro do laco, e cada leitura construia dois `pygame.Rect` novos —
    dezenas de objetos descartaveis por frame para uma geometria que muda uma vez
    por passo de simulacao (R27.3). Quem mexe em `pos` fora de `update`/`update_idle`
    precisa chamar `sync_rect`."""
    self.sync_rect()
draw(renderer, textures)

Consulta a textura do (frame de asa, angulo) atual e desenha (R27.2).

Nao ha rotacao aqui: as 62 combinacoes ja foram rotacionadas por precompute_sprites na inicializacao, e quantize garante que a consulta sempre caia numa delas. pygame.transform.rotate aloca uma superficie nova a cada chamada e reamostra o sprite inteiro — barato uma vez, caro sessenta vezes por segundo (R27.3).

Source code in src/bird.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def draw(self, renderer: render.Renderer, textures: dict[str, pygame.Surface]) -> None:
    """Consulta a textura do (frame de asa, angulo) atual e desenha (R27.2).

    Nao ha rotacao aqui: as 62 combinacoes ja foram rotacionadas por
    `precompute_sprites` na inicializacao, e `quantize` garante que a consulta
    sempre caia numa delas. `pygame.transform.rotate` aloca uma superficie nova a
    cada chamada e reamostra o sprite inteiro — barato uma vez, caro sessenta
    vezes por segundo (R27.3).
    """
    image = _sprite(renderer, textures, self.frame, quantize(self.angle))
    width, height = image.size
    center_x = self.pos.x + self.size / 2
    center_y = self.pos.y + self.size / 2
    renderer.draw(image, (round(center_x - width / 2), round(center_y - height / 2)))
flap()

Aplica o impulso de subida e vira o sprite para cima.

Source code in src/bird.py
58
59
60
61
def flap(self) -> None:
    """Aplica o impulso de subida e vira o sprite para cima."""
    self.vel_y = FLAP_IMPULSE
    self.angle = MAX_ANGLE_UP
sync_rect()

Recentra a hitbox persistente na posicao atual.

centerx/centery em vez de center = (x, y): a forma com tupla constroi um objeto por chamada, e a chamada acontece uma vez por passo de simulacao.

A conta e a mesma da property que existia aqui — round(pos) primeiro, meia largura depois — e nao round(pos + size / 2): para um size impar as duas divergem, e a hitbox nao pode mudar de lugar por causa de uma otimizacao.

Source code in src/bird.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
def sync_rect(self) -> None:
    """Recentra a hitbox persistente na posicao atual.

    `centerx`/`centery` em vez de `center = (x, y)`: a forma com tupla constroi um
    objeto por chamada, e a chamada acontece uma vez por passo de simulacao.

    A conta e a mesma da `property` que existia aqui — `round(pos)` primeiro, meia
    largura depois — e nao `round(pos + size / 2)`: para um `size` impar as duas
    divergem, e a hitbox nao pode mudar de lugar por causa de uma otimizacao.
    """
    self.rect.centerx = round(self.pos.x) + self.size // 2
    self.rect.centery = round(self.pos.y) + self.size // 2
update()

Avanca um passo de fisica: gravidade, posicao, rotacao e clamp no teto.

Source code in src/bird.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def update(self) -> None:
    """Avanca um passo de fisica: gravidade, posicao, rotacao e clamp no teto."""
    self.vel_y = min(self.vel_y + GRAVITY, MAX_FALL_SPEED)
    self.pos.y += self.vel_y

    # o teto fica na borda da AREA JOGAVEL, nao na do canvas: a faixa de ceu e
    # decorativa e nao pode dar espaco extra para voar (R24.5).
    ceiling = config.play().top
    if self.pos.y < ceiling:
        self.pos.y = ceiling
        self.vel_y = 0

    if self.vel_y < 0:
        self.angle = MAX_ANGLE_UP
    else:
        self.angle = max(MAX_ANGLE_DOWN, self.angle - ANGLE_FALL_STEP)

    self._advance_wing_frame()
    self.sync_rect()
update_idle()

Flutuacao senoidal no estado PRONTO, sem gravidade (R6.1).

Source code in src/bird.py
83
84
85
86
87
88
89
def update_idle(self) -> None:
    """Flutuacao senoidal no estado PRONTO, sem gravidade (R6.1)."""
    self._idle_timer += 1
    self.pos.y = self.base_y + math.sin(self._idle_timer * IDLE_BOB_SPEED) * IDLE_BOB_AMPLITUDE
    self.angle = 0
    self._advance_wing_frame()
    self.sync_rect()

precompute_sprites(renderer, textures)

Rotaciona as 62 combinacoes de (frame de asa, angulo) de uma vez (R27.2).

Chamada na inicializacao e de novo apos um redimensionamento, que descarta o cache de imagens do renderizador. Sem ela as texturas nasceriam sob demanda — uma por frame durante a primeira queda, que e justamente o momento em que o jogador esta olhando o movimento.

Source code in src/bird.py
149
150
151
152
153
154
155
156
157
158
159
def precompute_sprites(renderer: render.Renderer, textures: dict[str, pygame.Surface]) -> None:
    """Rotaciona as 62 combinacoes de (frame de asa, angulo) de uma vez (R27.2).

    Chamada na inicializacao e de novo apos um redimensionamento, que descarta o cache
    de imagens do renderizador. Sem ela as texturas nasceriam sob demanda — uma por
    frame durante a primeira queda, que e justamente o momento em que o jogador esta
    olhando o movimento.
    """
    for frame in WING_FRAMES:
        for angle in ANGLES:
            _sprite(renderer, textures, frame, angle)

quantize(angle)

Angulo da grade de ANGLES mais proximo de angle.

A fisica so produz angulos da grade, entao no jogo esta funcao devolve o proprio valor. Ela existe para que isso deixe de ser uma suposicao: sem ela, um angulo fora da grade — vindo de um estado restaurado, de um ajuste futuro no passo de queda ou de um teste — pediria uma textura que ninguem pre-computou, e a rotacao voltaria para dentro do frame, exatamente onde nao pode estar.

Source code in src/bird.py
126
127
128
129
130
131
132
133
134
135
136
def quantize(angle: float) -> int:
    """Angulo da grade de `ANGLES` mais proximo de `angle`.

    A fisica so produz angulos da grade, entao no jogo esta funcao devolve o proprio
    valor. Ela existe para que isso deixe de ser uma suposicao: sem ela, um angulo
    fora da grade — vindo de um estado restaurado, de um ajuste futuro no passo de
    queda ou de um teste — pediria uma textura que ninguem pre-computou, e a rotacao
    voltaria para dentro do frame, exatamente onde nao pode estar.
    """
    snapped = round(angle / ANGLE_FALL_STEP) * ANGLE_FALL_STEP
    return min(MAX_ANGLE_UP, max(MAX_ANGLE_DOWN, snapped))

src.config

src.config

Constantes globais do jogo.

CORNER_TOLERANCE = 4 module-attribute

Sobreposicao minima, em px, exigida nos dois eixos para contar como colisao (R3.6).

Bird.rect e sempre um quadrado reto (nunca acompanha o angulo de rotacao do sprite desenhado, de +30 a -60 graus), entao a caixa reta "sobra" alem do contorno visivel da abelha exatamente nas diagonais — onde ficam os cantos internos do vao das colunas e a quina do chao. Sem esta tolerancia, 1px de sobreposicao em qualquer eixo ja mata; com ela, um resvalar raso (fundo num eixo, raso no outro — a marca de um erro de AABB-vs-rotacao) deixa de contar, e uma batida de frente (que invade os dois eixos rapido) continua matando na mesma velocidade de sempre.

ground_y()

Topo do chao, na borda de baixo da area jogavel — nao na do canvas (R24.5).

O que fica abaixo dele e faixa decorativa de chao (R25.1): Ground.draw enche ate a base do canvas, entao a fileira extra e consequencia direta do canvas mais alto, sem regra nova.

Source code in src/config.py
88
89
90
91
92
93
94
95
def ground_y() -> int:
    """Topo do chao, na borda de baixo da area jogavel — nao na do canvas (R24.5).

    O que fica abaixo dele e faixa decorativa de chao (R25.1): `Ground.draw` enche
    ate a base do canvas, entao a fileira extra e consequencia direta do canvas mais
    alto, sem regra nova.
    """
    return play().bottom - GROUND_H

play()

Area jogavel dentro do canvas.

Onde a abelha voa, onde as colunas existem e onde a colisao acontece. Sempre 480x720 de mundo, em qualquer tela (R24.1).

Source code in src/config.py
79
80
81
82
83
84
85
def play() -> "pygame.Rect":
    """Area jogavel dentro do canvas.

    Onde a abelha voa, onde as colunas existem e onde a colisao acontece.
    Sempre 480x720 de mundo, em qualquer tela (R24.1).
    """
    return viewport().play

screen_h()

Altura do canvas logico ativo (R23.4). Ver screen_w().

Source code in src/config.py
74
75
76
def screen_h() -> int:
    """Altura do canvas logico ativo (R23.4). Ver `screen_w()`."""
    return viewport().canvas[1]

screen_w()

Largura do canvas logico ativo (R23.4).

Deixou de ser constante de modulo na v3: o canvas tem a proporcao da tela real, entao a largura so existe depois que o display foi criado. Funcao pelo mesmo motivo de ground_y() — uma constante importada congelaria o valor de quem importou antes do viewport ser definido.

Source code in src/config.py
63
64
65
66
67
68
69
70
71
def screen_w() -> int:
    """Largura do canvas logico ativo (R23.4).

    Deixou de ser constante de modulo na v3: o canvas tem a proporcao da tela real,
    entao a largura so existe depois que o display foi criado. Funcao pelo mesmo
    motivo de `ground_y()` — uma constante importada congelaria o valor de quem
    importou antes do viewport ser definido.
    """
    return viewport().canvas[0]

set_viewport(vp)

Define o viewport ativo.

Chamado pelo Game depois de criar o display e de novo a cada redimensionamento da janela (R23.6).

Source code in src/config.py
39
40
41
42
43
44
45
46
def set_viewport(vp: "Viewport") -> None:
    """Define o viewport ativo.

    Chamado pelo `Game` depois de criar o display e de novo a cada
    redimensionamento da janela (R23.6).
    """
    global _viewport
    _viewport = vp

viewport()

Viewport ativo.

Sem display criado (testes de unidade, scripts), o padrao e o canvas 2:3 — identico a area jogavel, que e exatamente o comportamento da v2.

Source code in src/config.py
49
50
51
52
53
54
55
56
57
58
59
60
def viewport() -> "Viewport":
    """Viewport ativo.

    Sem display criado (testes de unidade, scripts), o padrao e o canvas 2:3 —
    identico a area jogavel, que e exatamente o comportamento da v2.
    """
    global _viewport
    if _viewport is None:
        from src import viewport as viewport_module

        _viewport = viewport_module.compute(viewport_module.PLAY_W, viewport_module.PLAY_H)
    return _viewport

src.decor

src.decor

Decoracao de fundo com parallax, duas camadas por bioma (R7.4).

MIN_STRIP_TILES = 4 module-attribute

Piso de ladrilhos por faixa, para uma camada de periodo grande num canvas estreito nao virar uma faixa de dois ladrilhos, onde a repeticao seria obvia.

STRIP_SPANS = 3 module-attribute

Quantas larguras de canvas a faixa de uma camada cobre, no minimo.

O numero e um acordo entre memoria e repeticao. As formas do parallax sao deterministicas por indice, mas nao periodicas: a faixa e que impoe um periodo, e a partir dele o fundo se repete. Tres larguras dao dezenas de segundos de rolagem antes do ciclo fechar — mais que uma partida tipica — sem que uma camada custe mais que alguns megabytes.

DecorManager

Acompanha o deslocamento das duas camadas de parallax de fundo.

Source code in src/decor.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
class DecorManager:
    """Acompanha o deslocamento das duas camadas de parallax de fundo."""

    def __init__(self) -> None:
        """Inicia as duas camadas sem deslocamento."""
        self.far_scrolled = 0.0
        self.near_scrolled = 0.0

    def update(self, speed: float) -> None:
        """Avanca o deslocamento das duas camadas, cada uma no seu fator de parallax."""
        self.far_scrolled += speed * FAR_FACTOR
        self.near_scrolled += speed * NEAR_FACTOR

    def draw(self, renderer: render.Renderer, decor_id: str, *, far: bool = True, near: bool = True) -> None:
        """Duas faixas pre-renderizadas, ate quatro desenhos por frame (R27.2, R27.3).

        A v2 refazia as duas camadas a cada frame: uma dezena de ladrilhos, cada um
        com um `random.Random` novo — um Mersenne Twister de 624 palavras — so para
        sortear de novo a mesma forma que o indice ja determinava. Aqui o sorteio
        acontece uma vez, na construcao da faixa, e o frame vira um ou dois recortes
        por camada.

        `far` e `near` desligam cada camada por nivel de qualidade (R29.2). Quem decide
        e o `Game`, com a tabela de `quality.py`: este modulo nao conhece niveis, so
        recebe o que desenhar — a mesma disciplina de mao unica que mantem `mobs.py`
        sem dependencia de jogo (R25.4). A deriva continua sendo atualizada em
        `update()` mesmo com a camada desligada, para que voltar ao nivel de cima nao
        produza um salto no cenario.
        """
        # a linha do chao vem da area jogavel, nao da base do canvas: colinas, poças
        # de lava e pilares tem que assentar no chao de verdade, nao afundar na faixa
        # decorativa de baixo (R25.1).
        ground_y = config.ground_y()
        if far:
            self._layer(renderer, decor_id, 0, self.far_scrolled, ground_y)
        if near:
            self._layer(renderer, decor_id, 1, self.near_scrolled, ground_y)

    def _layer(
        self, renderer: render.Renderer, decor_id: str, layer: int, scrolled: float, ground_y: int
    ) -> None:
        """Desenha uma camada, cobrindo o canvas com ate dois recortes da faixa.

        O rolamento vira deslocamento da origem, como no chao (R27.2) — a diferenca e
        que aqui a faixa nao e mais larga que o canvas por uma folga fixa, e sim
        periodica: quando a origem chega perto do fim, o que falta vem do comeco, e e
        so isso que o segundo recorte faz.

        Ladrilha ate a largura do canvas logico (R25.5) — mais larga que a area
        jogavel sempre que a tela nao e 2:3, entao o parallax cobre a tela toda em vez
        de parar nos 480px de mundo.
        """
        period, drawer = _LAYERS[decor_id][layer]
        canvas_w = renderer.size[0]
        tiles = _tile_count(period, canvas_w)
        key = ("decor", decor_id, layer, period, tiles, ground_y)
        image = renderer.image(key, lambda: _build(key, drawer, period, tiles, ground_y))
        top = _STRIP_TOPS[key]
        width, height = image.size

        offset = round(scrolled) % width
        first = min(width - offset, canvas_w)
        renderer.draw(image, (0, top), pygame.Rect(offset, 0, first, height))
        if first < canvas_w:
            renderer.draw(image, (first, top), pygame.Rect(0, 0, canvas_w - first, height))
__init__()

Inicia as duas camadas sem deslocamento.

Source code in src/decor.py
249
250
251
252
def __init__(self) -> None:
    """Inicia as duas camadas sem deslocamento."""
    self.far_scrolled = 0.0
    self.near_scrolled = 0.0
draw(renderer, decor_id, *, far=True, near=True)

Duas faixas pre-renderizadas, ate quatro desenhos por frame (R27.2, R27.3).

A v2 refazia as duas camadas a cada frame: uma dezena de ladrilhos, cada um com um random.Random novo — um Mersenne Twister de 624 palavras — so para sortear de novo a mesma forma que o indice ja determinava. Aqui o sorteio acontece uma vez, na construcao da faixa, e o frame vira um ou dois recortes por camada.

far e near desligam cada camada por nivel de qualidade (R29.2). Quem decide e o Game, com a tabela de quality.py: este modulo nao conhece niveis, so recebe o que desenhar — a mesma disciplina de mao unica que mantem mobs.py sem dependencia de jogo (R25.4). A deriva continua sendo atualizada em update() mesmo com a camada desligada, para que voltar ao nivel de cima nao produza um salto no cenario.

Source code in src/decor.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
def draw(self, renderer: render.Renderer, decor_id: str, *, far: bool = True, near: bool = True) -> None:
    """Duas faixas pre-renderizadas, ate quatro desenhos por frame (R27.2, R27.3).

    A v2 refazia as duas camadas a cada frame: uma dezena de ladrilhos, cada um
    com um `random.Random` novo — um Mersenne Twister de 624 palavras — so para
    sortear de novo a mesma forma que o indice ja determinava. Aqui o sorteio
    acontece uma vez, na construcao da faixa, e o frame vira um ou dois recortes
    por camada.

    `far` e `near` desligam cada camada por nivel de qualidade (R29.2). Quem decide
    e o `Game`, com a tabela de `quality.py`: este modulo nao conhece niveis, so
    recebe o que desenhar — a mesma disciplina de mao unica que mantem `mobs.py`
    sem dependencia de jogo (R25.4). A deriva continua sendo atualizada em
    `update()` mesmo com a camada desligada, para que voltar ao nivel de cima nao
    produza um salto no cenario.
    """
    # a linha do chao vem da area jogavel, nao da base do canvas: colinas, poças
    # de lava e pilares tem que assentar no chao de verdade, nao afundar na faixa
    # decorativa de baixo (R25.1).
    ground_y = config.ground_y()
    if far:
        self._layer(renderer, decor_id, 0, self.far_scrolled, ground_y)
    if near:
        self._layer(renderer, decor_id, 1, self.near_scrolled, ground_y)
update(speed)

Avanca o deslocamento das duas camadas, cada uma no seu fator de parallax.

Source code in src/decor.py
254
255
256
257
def update(self, speed: float) -> None:
    """Avanca o deslocamento das duas camadas, cada uma no seu fator de parallax."""
    self.far_scrolled += speed * FAR_FACTOR
    self.near_scrolled += speed * NEAR_FACTOR

ore_vein_shapes(x, idx, ground_y)

Gera os quatro cubos de um veio de minerio, com a cor sorteada por idx.

Publico e devolvendo formas, em vez de desenhar, porque tem dois consumidores que pintam em alvos diferentes: a camada de parallax da Cave, que preenche pelo renderizador, e as faixas laterais, que constroem uma superficie uma unica vez (bands.py, R25.2).

Source code in src/decor.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
def ore_vein_shapes(x: int, idx: int, ground_y: int) -> Iterator[tuple[tuple[int, int, int], pygame.Rect]]:
    """Gera os quatro cubos de um veio de minerio, com a cor sorteada por `idx`.

    Publico e devolvendo formas, em vez de desenhar, porque tem dois consumidores que
    pintam em alvos diferentes: a camada de parallax da Cave, que preenche pelo
    renderizador, e as faixas laterais, que constroem uma superficie uma unica vez
    (`bands.py`, R25.2).
    """
    rng = random.Random(idx * 23 + 4)
    y = rng.randint(150, ground_y - 150)
    color = rng.choice([(120, 190, 255), (255, 215, 60), (200, 200, 210)])
    for _ in range(4):
        ox = rng.randint(-15, 15)
        oy = rng.randint(-15, 15)
        yield color, pygame.Rect(x + ox, y + oy, 6, 6)

src.game

src.game

Classe Game: loop principal e maquina de estados (R6).

MAX_FRAME_MS = 250.0 module-attribute

Teto do tempo real que um unico frame pode acumular (R28.3).

Uma pausa longa — o app em segundo plano, o sistema travando — devolveria um delta de segundos, e sem este corte ele viraria uma rajada de passos que faria o jogo pular para frente. R16.1 ja pausa o jogo ao ir para segundo plano; isto e a segunda linha de defesa, para o que nao vem acompanhado de evento nenhum.

MAX_STEPS = 5 module-attribute

Teto de passos de simulacao por frame (R28.2).

Impede a espiral da morte: se um passo passar a custar mais que STEP_MS, cada frame pediria mais passos do que consegue rodar, e a divida cresceria sem fim ate o jogo parar de responder. Chegando ao teto, o atraso restante e descartado — perde-se tempo de jogo, que e o preco de continuar respondendo.

RESIZE_SETTLE_FRAMES = 12 module-attribute

Frames sem novo evento de redimensionamento antes de aplicar o novo canvas — ~200ms a 60 FPS, o bastante para o arrasto assentar sem parecer travado.

STEP_MS = 1000 / FPS module-attribute

Duracao de um passo de simulacao, em milissegundos (R28.1).

update() continua sendo exatamente o que era: um passo logico de 1/60 s, com as mesmas constantes de fisica. O que mudou foi quem decide quantos deles rodam por frame — o tempo real, e nao mais o desenho.

Game

Orquestra estado, entrada, atualizacao e desenho do jogo inteiro.

Source code in src/game.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
class Game:
    """Orquestra estado, entrada, atualizacao e desenho do jogo inteiro."""

    def __init__(self) -> None:
        """Inicializa pygame, janela, renderer e todos os subsistemas do jogo."""
        # antes do init: o SDL le o hint de orientacao ao criar o subsistema de video,
        # e o `pygame.init()` ja sobe o mixer com os parametros que `pre_init` deixou —
        # e uma vez so, em vez das duas da v2 (R27.6, design secao 40).
        viewport.lock_portrait_orientation()
        sounds.pre_init()
        pygame.init()
        # logo apos o init, antes de qualquer evento existir: o que o jogo nao consome
        # nao chega nem a virar objeto Python (R27.6).
        configure_event_filter()
        # O canvas logico recebe a proporcao REAL da tela (Android) ou da janela
        # (desktop), com a area jogavel de 480x720 posicionada dentro dele — o que
        # sobra vira faixa decorativa, nunca barra preta (R23.1, R23.4). Como a
        # proporcao bate, a escala do SDL e uniforme e preenche a tela inteira sem
        # cortar nada (R23.2). O viewport vai para o `config` porque todo o jogo o
        # consulta para se posicionar.
        self.viewport = viewport.compute(*viewport.screen_size())
        config.set_viewport(self.viewport)
        self.renderer = render.create(
            self.viewport.canvas,
            viewport.screen_size(),
            fullscreen=is_android(),
            title=f"{TITLE} - {CREDITS}",
            icon=_load_icon(),
        )
        # descarta eventos de janela gerados pela criacao do display (ex.: WindowShown,
        # WindowFocusGained/Lost) para nao serem lidos como acoes do jogador antes do
        # loop comecar — visto sob SDL_VIDEODRIVER=dummy com SDL 2.32 (pygame-ce).
        pygame.event.clear()
        self._pending_resize: tuple[int, int] | None = None
        self._resize_idle = 0
        self.clock = pygame.time.Clock()
        self.accumulator = 0.0
        """Tempo real ja decorrido e ainda nao simulado, em milissegundos (R28.1).

        E o que faz o resto de um frame nao ser jogado fora: a 60 FPS o relogio do
        pygame devolve inteiros alternando entre 16 e 17 ms, e descartar a diferenca
        para `STEP_MS` (16,67) faria o jogo correr devagar num aparelho que nao perdeu
        um quadro sequer."""
        self.running = True
        self.textures = textures.generate_all(BLOCK)
        # as 62 rotacoes da abelha e os 18 sprites de mob entram no cache do
        # renderizador antes do primeiro frame, para que nenhum deles seja construido
        # durante o jogo (R27.2).
        precompute_sprites(self.renderer, self.textures)
        mobs.precompute(self.renderer)
        # cache de superficie por bioma, independente de partida: sobrevive ao reset()
        self.bands = SideBands()
        self.sounds = sounds.SoundManager()
        self.input = InputManager(self.renderer)
        self.score = 0
        self.last_score: int | None = None
        """Pontuacao da partida anterior nesta execucao, so em memoria (R36.3). `None`
        ate a primeira transicao GAME_OVER -> PRONTO; nunca gravado em disco."""
        self.highscore = score.load_highscore()
        self._highscore_dirty = False
        """Recorde superado e ainda nao gravado (R27.5). Ver `_flush_highscore`."""
        # o nivel detectado na sessao anterior volta ja aplicado: os primeiros segundos
        # ruins de um aparelho fraco acontecem uma vez, e nao toda vez (R29.5).
        self.quality = quality.Quality(quality.load_level())
        # None em producao: a instrumentacao so existe com BLOCKY_PERF ligado (R30.2),
        # entao o custo normal e uma comparacao contra None por frame.
        self.profiler = perf.FrameProfiler() if perf.enabled() else None
        if self.profiler is not None:
            self.profiler.backend = self.renderer.backend  # R26.5
        self.reset()
        # por ultimo: tudo que o jogo constroi uma vez ja existe, e e exatamente isso
        # que sai da varredura do coletor daqui em diante (R27.3).
        perf.tune_gc()

    def apply_resize(self, size: tuple[int, int]) -> None:
        """Recalcula o canvas e as faixas para uma janela `size` (R23.6).

        A area jogavel nao muda: continua sendo a mesma coluna de 480x720 de mundo,
        so o canvas ao redor e elastico (R24.1).

        Com a camada de render no lugar, trocar o canvas e uma chamada: o
        `display.quit()/init()` que a task 48 precisava para contornar o `SCALED`
        desapareceu junto com o `SCALED`. As imagens chaveadas pelo tamanho do canvas
        (gradiente de ceu, faixas laterais) sao descartadas para nao ficarem ocupando
        memoria de video sem nunca mais serem pedidas.

        `forget_images` nao sabe distinguir o que depende do canvas do que nao depende,
        entao as 62 rotacoes da abelha e os 18 sprites de mob caem junto e sao refeitos
        aqui — fora do frame, como na inicializacao.
        """
        new_viewport = viewport.compute(*size)
        if new_viewport.canvas == self.viewport.canvas:
            return
        self.viewport = new_viewport
        config.set_viewport(new_viewport)
        self.renderer.forget_images()
        self.renderer.resize(new_viewport.canvas)
        precompute_sprites(self.renderer, self.textures)
        mobs.precompute(self.renderer)
        # os retangulos de colisao viraram persistentes na task 58, e dois deles
        # dependem da linha do chao — que acabou de mudar de lugar. Sem isto, os frames
        # entre o redimensionamento e o proximo passo de simulacao (PRONTO, PAUSADO,
        # GAME_OVER nao simulam) desenhariam a coluna com a altura do canvas antigo.
        self.pipes.sync_rects()
        self.ground.sync_rect()

    def _tick_resize(self) -> None:
        """Aplica o redimensionamento pendente quando o arrasto para.

        Sem esperar o arrasto assentar, cada pixel de uma janela sendo arrastada
        recriaria o display — dezenas de vezes por segundo.
        """
        if self._pending_resize is None:
            return
        self._resize_idle += 1
        if self._resize_idle >= RESIZE_SETTLE_FRAMES:
            size, self._pending_resize = self._pending_resize, None
            self.apply_resize(size)

    def reset(self) -> None:
        """Recria todo o estado de uma partida nova (passaro, biomas, colunas, score)."""
        play = config.play()
        self.bird = Bird(play.x + play.width // 4, play.y + play.height // 2)
        self.biome = BiomeManager()
        b = self.biome.current
        self.pipes = PipeManager(b.gap_size, b.block_main, b.block_edge)
        self.ground = Ground()
        self.decor = DecorManager()
        self.mobs = mobs.MobField()
        self.particles = ParticleSystem()
        self.score = 0
        self.state = GameState.PRONTO

    def _flap_action(self) -> None:
        if self.state == GameState.PRONTO:
            self.state = GameState.JOGANDO
            self.bird.flap()
            self.sounds.play("flap")
        elif self.state == GameState.JOGANDO:
            self.bird.flap()
            self.sounds.play("flap")
        elif self.state == GameState.PAUSADO:
            # despausa sem flapar: garante que todo estado seja alcancavel so
            # com o botao central do D-pad/toque, mesmo sem tecla de pause
            # dedicada (ESC/P) ou botao Start de gamepad (R14.4, R15.5) — sem
            # isso, quem pausa via BACK (task 23) ficaria sem como voltar.
            self.state = GameState.JOGANDO
        elif self.state == GameState.GAME_OVER:
            self.last_score = self.score
            self.reset()

    def _toggle_pause(self) -> None:
        if self.state == GameState.JOGANDO:
            self.state = GameState.PAUSADO
        elif self.state == GameState.PAUSADO:
            self.state = GameState.JOGANDO

    def _back_action(self) -> None:
        """BACK do Android pausa em JOGANDO; nos demais estados, encerra o jogo.

        (R15.2, R15.3). Fica no Game (que conhece o estado), nao no
        InputManager, mantendo a separacao acao/estado da v1.
        """
        if self.state == GameState.JOGANDO:
            self._toggle_pause()
        else:
            self.running = False

    def handle_events(self) -> None:
        """Coleta as acoes de entrada do quadro e as aplica ao estado do jogo."""
        actions, quit_requested = self.input.poll()
        if quit_requested:
            self.running = False
        if ACTION_RESIZE in actions:
            self._pending_resize = self.renderer.window_size
            self._resize_idle = 0
        self._tick_resize()
        if ACTION_FOCUS_LOST in actions:
            # ultimo instante garantido antes de o Android poder encerrar o app: e onde
            # o recorde da partida em curso vai para o disco (R27.5, R16.4). Fora do
            # `if` de estado de proposito — o recorde pode estar sujo em GAME_OVER que
            # falhou em gravar, e gravar de novo custa nada quando nao ha nada sujo.
            self._flush_to_disk()
            if self.state == GameState.JOGANDO:
                # o app foi para segundo plano (ou perdeu foco no desktop): pausa e
                # nunca retoma sozinho, mesmo quando volta ao primeiro plano (R16.1,
                # R16.2) — so um flap/pause explicito do jogador despausa.
                self._toggle_pause()
        if ACTION_BACK in actions:
            self._back_action()
        if ACTION_FLAP in actions:
            self._flap_action()
        if ACTION_PAUSE in actions:
            self._toggle_pause()
        if ACTION_MUTE in actions:
            self.sounds.toggle_mute()
        if self.state == GameState.PAUSADO and (ACTION_LEFT in actions or ACTION_RIGHT in actions):
            # D-pad esquerda/direita alterna mudo em PAUSADO — unica forma de
            # mudar sem tecla M nem toque, para Android TV (R14.4, R15.4).
            self.sounds.toggle_mute()

    def _collision_texture(self) -> str | None:
        """Retorna a chave da textura do bloco atingido, ou None se nao houve colisao (R3.2, R3.6)."""
        if _collides(self.bird.rect, self.ground.rect):
            return self.biome.current.block_main
        for pipe in self.pipes.pipes:
            if _collides(self.bird.rect, pipe.top_rect) or _collides(self.bird.rect, pipe.bottom_rect):
                return pipe.block_main
        return None

    def _update_score(self) -> None:
        """+1 por coluna ultrapassada, uma unica vez por coluna (R4.1).

        Superar o recorde marca-o como sujo em vez de gravar em disco: escrita de
        arquivo dentro do frame de JOGANDO e uma chamada de sistema sincrona, com uma
        cauda de latencia que nao depende do jogo (R27.5). Quem grava e
        `_flush_highscore`.
        """
        for pipe in self.pipes.pipes:
            if not pipe.scored and pipe.x + PIPE_W < self.bird.pos.x:
                pipe.scored = True
                self.score += 1
                self.sounds.play("score")
                if self.score > self.highscore:
                    self.highscore = self.score
                    self._highscore_dirty = True

    def _flush_highscore(self) -> None:
        """Grava o recorde em disco, se houver um novo desde a ultima gravacao (R27.5).

        Chamado nos tres momentos em que o frame nao esta em jogo: o fim da partida, a
        ida para segundo plano e o encerramento do laco. A garantia que motivou a
        gravacao incremental da v2 continua de pe (R4.3, R16.4) porque o Android *avisa*
        antes de encerrar — `APP_WILLENTERBACKGROUND` chega primeiro, e e nele que a
        gravacao passa a acontecer. O que se perde e o caso de o processo morrer sem
        nenhum aviso, que nem a v2 cobria.
        """
        if self._highscore_dirty:
            score.save_highscore(self.highscore)
            self._highscore_dirty = False

    def _flush_quality(self) -> None:
        """Grava o nivel de qualidade detectado, se ele mudou (R29.5).

        Sai do frame pelo mesmo motivo do recorde: a troca de nivel acontece justamente
        no aparelho que ja esta com dificuldade, e escrever em disco ali seria uma
        chamada de sistema sincrona no pior momento possivel (R27.5).
        """
        if self.quality.dirty:
            quality.save_level(self.quality.level)
            self.quality.dirty = False

    def _flush_to_disk(self) -> None:
        """Descarrega tudo que esta pendente de gravacao.

        Os dois arquivos sao gravados nos mesmos tres momentos — fim de partida, ida
        para segundo plano e saida do laco — porque a razao e a mesma: sao os unicos
        instantes em que ninguem esta jogando.
        """
        self._flush_highscore()
        self._flush_quality()

    def update(self) -> None:
        """Avanca um passo fixo de simulacao conforme o estado atual do jogo."""
        if self.state == GameState.PRONTO:
            self.bird.update_idle()
            # sem deriva, so o idle: o cenario respira na tela inicial sem sair do lugar
            self.mobs.update(0.0)
        elif self.state == GameState.JOGANDO:
            b = self.biome.current
            self.bird.update()
            self.pipes.update(b.speed, b.gap_size, b.block_main, b.block_edge)
            self.ground.update(b.speed)
            self.decor.update(b.speed)
            self.mobs.update(b.speed)
            self._update_score()
            if self.biome.update(self.score):
                self.sounds.play("portal")
            hit_texture = self._collision_texture()
            if hit_texture is not None:
                self.state = GameState.GAME_OVER
                self.particles.burst(
                    self.bird.rect.center, self.textures[hit_texture], self.quality.settings.particles
                )
                self.sounds.play("hit")
                # fim da partida: e aqui que o recorde da rodada vai para o disco (R27.5)
                self._flush_to_disk()
        # PAUSADO: fisica, obstaculos, biomas e particulas ficam congelados (R3.3, R6.3).
        if self.state != GameState.PAUSADO:
            self.particles.update()

    def draw(self) -> None:
        """Desenha um quadro completo: fundo, decoracao, jogo e UI do estado atual."""
        b = self.biome.current
        renderer = self.renderer
        # o que cai fora nos niveis mais baixos e sempre decoracao — nunca coluna,
        # chao, abelha ou HUD, que sao o jogo (R29.2, R29.3).
        level = self.quality.settings
        self.biome.draw_background(renderer)
        self.decor.draw(renderer, b.decor, far=level.far_parallax, near=level.near_parallax)
        self.pipes.draw(renderer, self.textures)
        self.ground.draw(renderer, self.textures, b.block_main, b.block_edge)
        # depois do chao: o mob vive sobre a terra estendida, nao antes dela.
        if level.mobs:
            self.mobs.draw_ground(renderer, b.id)
        self.bird.draw(renderer, self.textures)
        self.particles.draw(renderer)
        # depois das colunas e da abelha, antes do HUD: a faixa e opaca e esconde a
        # coluna que ainda nao entrou na area jogavel (R24.4, seção 32.6).
        self.bands.draw(renderer, self.textures, b)
        # depois das faixas, que sao opacas: o mob vive NA parede, nao atras dela.
        if level.mobs:
            self.mobs.draw_sides(renderer, b.id)
        self.biome.draw_banner(renderer)
        ui.draw_mute_icon(renderer, self.sounds.muted)

        if self.state == GameState.PRONTO:
            ui.draw_ready_screen(renderer, self.highscore, self.last_score)
        elif self.state == GameState.JOGANDO:
            ui.draw_hud_score(renderer, self.score)
        elif self.state == GameState.PAUSADO:
            ui.draw_hud_score(renderer, self.score)
            ui.draw_paused_overlay(renderer)
        elif self.state == GameState.GAME_OVER:
            ui.draw_game_over_screen(renderer, self.score, self.highscore)

        if self.profiler is not None:
            perf.draw_overlay(renderer, self.profiler)

        renderer.present()

    def simulate(self, frame_ms: float) -> int:
        """Roda os passos fixos que `frame_ms` de tempo real comprou (R28.1-R28.3).

        Devolve quantos rodaram. A conta e a mesma de qualquer acumulador: o tempo
        decorrido entra, os passos inteiros saem, e o resto fica para o frame seguinte.
        Tres detalhes e que sao o requisito:

        - o delta e **cortado antes** de virar passos (R28.3), entao uma pausa de dois
          segundos vira 250 ms de jogo e nao dois segundos de avanco instantaneo;
        - o numero de passos tem teto (R28.2), entao um aparelho onde o passo custa
          mais que 1/60 s desiste de recuperar em vez de afundar;
        - batido o teto, o acumulador **zera**. Sem isso a divida ficaria guardada e o
          frame seguinte comecaria ja devendo, o que e a mesma espiral, so que mais
          lenta.

        O que nao muda e o principal: `update()` continua sendo um passo de 1/60 s. A
        60 FPS este laco roda exatamente um por frame, e o jogo e o mesmo da v2 ate no
        pixel (R28.4).
        """
        self.accumulator += min(frame_ms, MAX_FRAME_MS)
        steps = 0
        while self.accumulator >= STEP_MS and steps < MAX_STEPS:
            self.update()
            self.accumulator -= STEP_MS
            steps += 1
        if steps == MAX_STEPS:
            self.accumulator = 0.0
        return steps

    def run(self) -> None:
        """Laco principal: relogio, simulacao em passos fixos e desenho, ate fechar."""
        profiler = self.profiler
        while self.running:
            # o teto de quadros vem do nivel de qualidade: cai para 30 no BAIXO, e a
            # simulacao continua a 60 passos logicos por segundo (R29.2, seção 38).
            frame_ms = self.clock.tick(self.quality.render_fps)
            # so JOGANDO alimenta a medicao: as telas paradas desenham outra coisa e
            # nao dizem nada sobre o desempenho da partida (R29.1).
            self.quality.frame(frame_ms, self.state == GameState.JOGANDO)
            self.handle_events()
            if profiler is None:
                self.simulate(frame_ms)
                self.draw()
            else:
                profiler.frame(frame_ms)
                profiler.begin()
                self.simulate(frame_ms)
                profiler.end_update()
                self.draw()
                profiler.end_draw()
        # saida ordenada (fechar a janela, BACK fora de JOGANDO): um recorde batido numa
        # partida que o jogador abandonou sem colidir nao pode se perder aqui (R4.3).
        self._flush_to_disk()
        pygame.quit()
accumulator = 0.0 instance-attribute

Tempo real ja decorrido e ainda nao simulado, em milissegundos (R28.1).

E o que faz o resto de um frame nao ser jogado fora: a 60 FPS o relogio do pygame devolve inteiros alternando entre 16 e 17 ms, e descartar a diferenca para STEP_MS (16,67) faria o jogo correr devagar num aparelho que nao perdeu um quadro sequer.

last_score = None instance-attribute

Pontuacao da partida anterior nesta execucao, so em memoria (R36.3). None ate a primeira transicao GAME_OVER -> PRONTO; nunca gravado em disco.

__init__()

Inicializa pygame, janela, renderer e todos os subsistemas do jogo.

Source code in src/game.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
def __init__(self) -> None:
    """Inicializa pygame, janela, renderer e todos os subsistemas do jogo."""
    # antes do init: o SDL le o hint de orientacao ao criar o subsistema de video,
    # e o `pygame.init()` ja sobe o mixer com os parametros que `pre_init` deixou —
    # e uma vez so, em vez das duas da v2 (R27.6, design secao 40).
    viewport.lock_portrait_orientation()
    sounds.pre_init()
    pygame.init()
    # logo apos o init, antes de qualquer evento existir: o que o jogo nao consome
    # nao chega nem a virar objeto Python (R27.6).
    configure_event_filter()
    # O canvas logico recebe a proporcao REAL da tela (Android) ou da janela
    # (desktop), com a area jogavel de 480x720 posicionada dentro dele — o que
    # sobra vira faixa decorativa, nunca barra preta (R23.1, R23.4). Como a
    # proporcao bate, a escala do SDL e uniforme e preenche a tela inteira sem
    # cortar nada (R23.2). O viewport vai para o `config` porque todo o jogo o
    # consulta para se posicionar.
    self.viewport = viewport.compute(*viewport.screen_size())
    config.set_viewport(self.viewport)
    self.renderer = render.create(
        self.viewport.canvas,
        viewport.screen_size(),
        fullscreen=is_android(),
        title=f"{TITLE} - {CREDITS}",
        icon=_load_icon(),
    )
    # descarta eventos de janela gerados pela criacao do display (ex.: WindowShown,
    # WindowFocusGained/Lost) para nao serem lidos como acoes do jogador antes do
    # loop comecar — visto sob SDL_VIDEODRIVER=dummy com SDL 2.32 (pygame-ce).
    pygame.event.clear()
    self._pending_resize: tuple[int, int] | None = None
    self._resize_idle = 0
    self.clock = pygame.time.Clock()
    self.accumulator = 0.0
    """Tempo real ja decorrido e ainda nao simulado, em milissegundos (R28.1).

    E o que faz o resto de um frame nao ser jogado fora: a 60 FPS o relogio do
    pygame devolve inteiros alternando entre 16 e 17 ms, e descartar a diferenca
    para `STEP_MS` (16,67) faria o jogo correr devagar num aparelho que nao perdeu
    um quadro sequer."""
    self.running = True
    self.textures = textures.generate_all(BLOCK)
    # as 62 rotacoes da abelha e os 18 sprites de mob entram no cache do
    # renderizador antes do primeiro frame, para que nenhum deles seja construido
    # durante o jogo (R27.2).
    precompute_sprites(self.renderer, self.textures)
    mobs.precompute(self.renderer)
    # cache de superficie por bioma, independente de partida: sobrevive ao reset()
    self.bands = SideBands()
    self.sounds = sounds.SoundManager()
    self.input = InputManager(self.renderer)
    self.score = 0
    self.last_score: int | None = None
    """Pontuacao da partida anterior nesta execucao, so em memoria (R36.3). `None`
    ate a primeira transicao GAME_OVER -> PRONTO; nunca gravado em disco."""
    self.highscore = score.load_highscore()
    self._highscore_dirty = False
    """Recorde superado e ainda nao gravado (R27.5). Ver `_flush_highscore`."""
    # o nivel detectado na sessao anterior volta ja aplicado: os primeiros segundos
    # ruins de um aparelho fraco acontecem uma vez, e nao toda vez (R29.5).
    self.quality = quality.Quality(quality.load_level())
    # None em producao: a instrumentacao so existe com BLOCKY_PERF ligado (R30.2),
    # entao o custo normal e uma comparacao contra None por frame.
    self.profiler = perf.FrameProfiler() if perf.enabled() else None
    if self.profiler is not None:
        self.profiler.backend = self.renderer.backend  # R26.5
    self.reset()
    # por ultimo: tudo que o jogo constroi uma vez ja existe, e e exatamente isso
    # que sai da varredura do coletor daqui em diante (R27.3).
    perf.tune_gc()
apply_resize(size)

Recalcula o canvas e as faixas para uma janela size (R23.6).

A area jogavel nao muda: continua sendo a mesma coluna de 480x720 de mundo, so o canvas ao redor e elastico (R24.1).

Com a camada de render no lugar, trocar o canvas e uma chamada: o display.quit()/init() que a task 48 precisava para contornar o SCALED desapareceu junto com o SCALED. As imagens chaveadas pelo tamanho do canvas (gradiente de ceu, faixas laterais) sao descartadas para nao ficarem ocupando memoria de video sem nunca mais serem pedidas.

forget_images nao sabe distinguir o que depende do canvas do que nao depende, entao as 62 rotacoes da abelha e os 18 sprites de mob caem junto e sao refeitos aqui — fora do frame, como na inicializacao.

Source code in src/game.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
def apply_resize(self, size: tuple[int, int]) -> None:
    """Recalcula o canvas e as faixas para uma janela `size` (R23.6).

    A area jogavel nao muda: continua sendo a mesma coluna de 480x720 de mundo,
    so o canvas ao redor e elastico (R24.1).

    Com a camada de render no lugar, trocar o canvas e uma chamada: o
    `display.quit()/init()` que a task 48 precisava para contornar o `SCALED`
    desapareceu junto com o `SCALED`. As imagens chaveadas pelo tamanho do canvas
    (gradiente de ceu, faixas laterais) sao descartadas para nao ficarem ocupando
    memoria de video sem nunca mais serem pedidas.

    `forget_images` nao sabe distinguir o que depende do canvas do que nao depende,
    entao as 62 rotacoes da abelha e os 18 sprites de mob caem junto e sao refeitos
    aqui — fora do frame, como na inicializacao.
    """
    new_viewport = viewport.compute(*size)
    if new_viewport.canvas == self.viewport.canvas:
        return
    self.viewport = new_viewport
    config.set_viewport(new_viewport)
    self.renderer.forget_images()
    self.renderer.resize(new_viewport.canvas)
    precompute_sprites(self.renderer, self.textures)
    mobs.precompute(self.renderer)
    # os retangulos de colisao viraram persistentes na task 58, e dois deles
    # dependem da linha do chao — que acabou de mudar de lugar. Sem isto, os frames
    # entre o redimensionamento e o proximo passo de simulacao (PRONTO, PAUSADO,
    # GAME_OVER nao simulam) desenhariam a coluna com a altura do canvas antigo.
    self.pipes.sync_rects()
    self.ground.sync_rect()
draw()

Desenha um quadro completo: fundo, decoracao, jogo e UI do estado atual.

Source code in src/game.py
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
def draw(self) -> None:
    """Desenha um quadro completo: fundo, decoracao, jogo e UI do estado atual."""
    b = self.biome.current
    renderer = self.renderer
    # o que cai fora nos niveis mais baixos e sempre decoracao — nunca coluna,
    # chao, abelha ou HUD, que sao o jogo (R29.2, R29.3).
    level = self.quality.settings
    self.biome.draw_background(renderer)
    self.decor.draw(renderer, b.decor, far=level.far_parallax, near=level.near_parallax)
    self.pipes.draw(renderer, self.textures)
    self.ground.draw(renderer, self.textures, b.block_main, b.block_edge)
    # depois do chao: o mob vive sobre a terra estendida, nao antes dela.
    if level.mobs:
        self.mobs.draw_ground(renderer, b.id)
    self.bird.draw(renderer, self.textures)
    self.particles.draw(renderer)
    # depois das colunas e da abelha, antes do HUD: a faixa e opaca e esconde a
    # coluna que ainda nao entrou na area jogavel (R24.4, seção 32.6).
    self.bands.draw(renderer, self.textures, b)
    # depois das faixas, que sao opacas: o mob vive NA parede, nao atras dela.
    if level.mobs:
        self.mobs.draw_sides(renderer, b.id)
    self.biome.draw_banner(renderer)
    ui.draw_mute_icon(renderer, self.sounds.muted)

    if self.state == GameState.PRONTO:
        ui.draw_ready_screen(renderer, self.highscore, self.last_score)
    elif self.state == GameState.JOGANDO:
        ui.draw_hud_score(renderer, self.score)
    elif self.state == GameState.PAUSADO:
        ui.draw_hud_score(renderer, self.score)
        ui.draw_paused_overlay(renderer)
    elif self.state == GameState.GAME_OVER:
        ui.draw_game_over_screen(renderer, self.score, self.highscore)

    if self.profiler is not None:
        perf.draw_overlay(renderer, self.profiler)

    renderer.present()
handle_events()

Coleta as acoes de entrada do quadro e as aplica ao estado do jogo.

Source code in src/game.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
def handle_events(self) -> None:
    """Coleta as acoes de entrada do quadro e as aplica ao estado do jogo."""
    actions, quit_requested = self.input.poll()
    if quit_requested:
        self.running = False
    if ACTION_RESIZE in actions:
        self._pending_resize = self.renderer.window_size
        self._resize_idle = 0
    self._tick_resize()
    if ACTION_FOCUS_LOST in actions:
        # ultimo instante garantido antes de o Android poder encerrar o app: e onde
        # o recorde da partida em curso vai para o disco (R27.5, R16.4). Fora do
        # `if` de estado de proposito — o recorde pode estar sujo em GAME_OVER que
        # falhou em gravar, e gravar de novo custa nada quando nao ha nada sujo.
        self._flush_to_disk()
        if self.state == GameState.JOGANDO:
            # o app foi para segundo plano (ou perdeu foco no desktop): pausa e
            # nunca retoma sozinho, mesmo quando volta ao primeiro plano (R16.1,
            # R16.2) — so um flap/pause explicito do jogador despausa.
            self._toggle_pause()
    if ACTION_BACK in actions:
        self._back_action()
    if ACTION_FLAP in actions:
        self._flap_action()
    if ACTION_PAUSE in actions:
        self._toggle_pause()
    if ACTION_MUTE in actions:
        self.sounds.toggle_mute()
    if self.state == GameState.PAUSADO and (ACTION_LEFT in actions or ACTION_RIGHT in actions):
        # D-pad esquerda/direita alterna mudo em PAUSADO — unica forma de
        # mudar sem tecla M nem toque, para Android TV (R14.4, R15.4).
        self.sounds.toggle_mute()
reset()

Recria todo o estado de uma partida nova (passaro, biomas, colunas, score).

Source code in src/game.py
210
211
212
213
214
215
216
217
218
219
220
221
222
def reset(self) -> None:
    """Recria todo o estado de uma partida nova (passaro, biomas, colunas, score)."""
    play = config.play()
    self.bird = Bird(play.x + play.width // 4, play.y + play.height // 2)
    self.biome = BiomeManager()
    b = self.biome.current
    self.pipes = PipeManager(b.gap_size, b.block_main, b.block_edge)
    self.ground = Ground()
    self.decor = DecorManager()
    self.mobs = mobs.MobField()
    self.particles = ParticleSystem()
    self.score = 0
    self.state = GameState.PRONTO
run()

Laco principal: relogio, simulacao em passos fixos e desenho, ate fechar.

Source code in src/game.py
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
def run(self) -> None:
    """Laco principal: relogio, simulacao em passos fixos e desenho, ate fechar."""
    profiler = self.profiler
    while self.running:
        # o teto de quadros vem do nivel de qualidade: cai para 30 no BAIXO, e a
        # simulacao continua a 60 passos logicos por segundo (R29.2, seção 38).
        frame_ms = self.clock.tick(self.quality.render_fps)
        # so JOGANDO alimenta a medicao: as telas paradas desenham outra coisa e
        # nao dizem nada sobre o desempenho da partida (R29.1).
        self.quality.frame(frame_ms, self.state == GameState.JOGANDO)
        self.handle_events()
        if profiler is None:
            self.simulate(frame_ms)
            self.draw()
        else:
            profiler.frame(frame_ms)
            profiler.begin()
            self.simulate(frame_ms)
            profiler.end_update()
            self.draw()
            profiler.end_draw()
    # saida ordenada (fechar a janela, BACK fora de JOGANDO): um recorde batido numa
    # partida que o jogador abandonou sem colidir nao pode se perder aqui (R4.3).
    self._flush_to_disk()
    pygame.quit()
simulate(frame_ms)

Roda os passos fixos que frame_ms de tempo real comprou (R28.1-R28.3).

Devolve quantos rodaram. A conta e a mesma de qualquer acumulador: o tempo decorrido entra, os passos inteiros saem, e o resto fica para o frame seguinte. Tres detalhes e que sao o requisito:

  • o delta e cortado antes de virar passos (R28.3), entao uma pausa de dois segundos vira 250 ms de jogo e nao dois segundos de avanco instantaneo;
  • o numero de passos tem teto (R28.2), entao um aparelho onde o passo custa mais que 1/60 s desiste de recuperar em vez de afundar;
  • batido o teto, o acumulador zera. Sem isso a divida ficaria guardada e o frame seguinte comecaria ja devendo, o que e a mesma espiral, so que mais lenta.

O que nao muda e o principal: update() continua sendo um passo de 1/60 s. A 60 FPS este laco roda exatamente um por frame, e o jogo e o mesmo da v2 ate no pixel (R28.4).

Source code in src/game.py
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
def simulate(self, frame_ms: float) -> int:
    """Roda os passos fixos que `frame_ms` de tempo real comprou (R28.1-R28.3).

    Devolve quantos rodaram. A conta e a mesma de qualquer acumulador: o tempo
    decorrido entra, os passos inteiros saem, e o resto fica para o frame seguinte.
    Tres detalhes e que sao o requisito:

    - o delta e **cortado antes** de virar passos (R28.3), entao uma pausa de dois
      segundos vira 250 ms de jogo e nao dois segundos de avanco instantaneo;
    - o numero de passos tem teto (R28.2), entao um aparelho onde o passo custa
      mais que 1/60 s desiste de recuperar em vez de afundar;
    - batido o teto, o acumulador **zera**. Sem isso a divida ficaria guardada e o
      frame seguinte comecaria ja devendo, o que e a mesma espiral, so que mais
      lenta.

    O que nao muda e o principal: `update()` continua sendo um passo de 1/60 s. A
    60 FPS este laco roda exatamente um por frame, e o jogo e o mesmo da v2 ate no
    pixel (R28.4).
    """
    self.accumulator += min(frame_ms, MAX_FRAME_MS)
    steps = 0
    while self.accumulator >= STEP_MS and steps < MAX_STEPS:
        self.update()
        self.accumulator -= STEP_MS
        steps += 1
    if steps == MAX_STEPS:
        self.accumulator = 0.0
    return steps
update()

Avanca um passo fixo de simulacao conforme o estado atual do jogo.

Source code in src/game.py
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
def update(self) -> None:
    """Avanca um passo fixo de simulacao conforme o estado atual do jogo."""
    if self.state == GameState.PRONTO:
        self.bird.update_idle()
        # sem deriva, so o idle: o cenario respira na tela inicial sem sair do lugar
        self.mobs.update(0.0)
    elif self.state == GameState.JOGANDO:
        b = self.biome.current
        self.bird.update()
        self.pipes.update(b.speed, b.gap_size, b.block_main, b.block_edge)
        self.ground.update(b.speed)
        self.decor.update(b.speed)
        self.mobs.update(b.speed)
        self._update_score()
        if self.biome.update(self.score):
            self.sounds.play("portal")
        hit_texture = self._collision_texture()
        if hit_texture is not None:
            self.state = GameState.GAME_OVER
            self.particles.burst(
                self.bird.rect.center, self.textures[hit_texture], self.quality.settings.particles
            )
            self.sounds.play("hit")
            # fim da partida: e aqui que o recorde da rodada vai para o disco (R27.5)
            self._flush_to_disk()
    # PAUSADO: fisica, obstaculos, biomas e particulas ficam congelados (R3.3, R6.3).
    if self.state != GameState.PAUSADO:
        self.particles.update()

GameState

Bases: Enum

Os quatro estados da maquina de estados do jogo (R6).

Source code in src/game.py
82
83
84
85
86
87
88
class GameState(Enum):
    """Os quatro estados da maquina de estados do jogo (R6)."""

    PRONTO = auto()
    JOGANDO = auto()
    PAUSADO = auto()
    GAME_OVER = auto()

src.ground

src.ground

Chao rolante de blocos, sincronizado a velocidade das colunas (R7.3).

Ground

Chao rolante: deslocamento visual e faixa de colisao fixa na base do canvas.

Source code in src/ground.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class Ground:
    """Chao rolante: deslocamento visual e faixa de colisao fixa na base do canvas."""

    def __init__(self) -> None:
        """Inicia sem deslocamento, com a faixa de colisao na linha do chao atual."""
        self.offset = 0.0
        self.rect = pygame.Rect(0, config.ground_y(), config.screen_w(), GROUND_H)
        """Faixa de colisao do chao, construida uma vez e movida no lugar (R27.3)."""

    def update(self, speed: float) -> None:
        """Avanca o deslocamento visual do chao pela velocidade atual."""
        self.offset = (self.offset - speed) % BLOCK

    def sync_rect(self) -> None:
        """Reancora a faixa de colisao na linha do chao e na largura do canvas atuais.

        Nao e chamada no passo de simulacao, ao contrario das irmas em `Bird` e
        `PipePair`: a geometria do chao nao depende de nada que role durante a partida,
        so do canvas. Quem chama e `Game.apply_resize`. Um `sync_rect()` dentro de
        `update` seria codigo que nenhum teste consegue justificar — foi escrito,
        sobreviveu a rodada de mutacao por ser inerte, e saiu.
        """
        self.rect.y = config.ground_y()
        self.rect.width = config.screen_w()

    def draw(
        self,
        renderer: render.Renderer,
        textures: dict[str, pygame.Surface],
        block_main: str,
        block_edge: str,
    ) -> None:
        """Uma faixa pre-renderizada, um desenho por frame (R27.2).

        A v2 refazia a mesma parede de blocos a cada frame — 22 blits num canvas de
        480 de largura, e mais quanto maior a tela — para um resultado que so difere
        entre frames por alguns pixels de deslocamento horizontal. Aqui a parede e
        construida uma vez por par de blocos e o rolamento vira o retangulo de
        origem andando dentro dela.

        Continua usando as texturas do bioma atual, sem congelamento (R7.3): o par de
        blocos entra na chave da faixa, entao trocar de bioma pede outra faixa em vez
        de reaproveitar a anterior.
        """
        top_y = config.ground_y()
        canvas_w = config.screen_w()
        height = config.screen_h() - top_y
        strip = renderer.image(
            ("ground", block_main, block_edge, canvas_w, height),
            lambda: _make_strip(textures[block_main], textures[block_edge], canvas_w, height),
        )
        # a faixa e periodica em BLOCK, entao deslocar a origem de 0 a BLOCK cobre o
        # ciclo inteiro de rolamento; `offset` ja vem reduzido modulo BLOCK.
        renderer.draw(strip, (0, top_y), pygame.Rect(BLOCK - round(self.offset), 0, canvas_w, height))
rect = pygame.Rect(0, config.ground_y(), config.screen_w(), GROUND_H) instance-attribute

Faixa de colisao do chao, construida uma vez e movida no lugar (R27.3).

__init__()

Inicia sem deslocamento, com a faixa de colisao na linha do chao atual.

Source code in src/ground.py
12
13
14
15
16
def __init__(self) -> None:
    """Inicia sem deslocamento, com a faixa de colisao na linha do chao atual."""
    self.offset = 0.0
    self.rect = pygame.Rect(0, config.ground_y(), config.screen_w(), GROUND_H)
    """Faixa de colisao do chao, construida uma vez e movida no lugar (R27.3)."""
draw(renderer, textures, block_main, block_edge)

Uma faixa pre-renderizada, um desenho por frame (R27.2).

A v2 refazia a mesma parede de blocos a cada frame — 22 blits num canvas de 480 de largura, e mais quanto maior a tela — para um resultado que so difere entre frames por alguns pixels de deslocamento horizontal. Aqui a parede e construida uma vez por par de blocos e o rolamento vira o retangulo de origem andando dentro dela.

Continua usando as texturas do bioma atual, sem congelamento (R7.3): o par de blocos entra na chave da faixa, entao trocar de bioma pede outra faixa em vez de reaproveitar a anterior.

Source code in src/ground.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def draw(
    self,
    renderer: render.Renderer,
    textures: dict[str, pygame.Surface],
    block_main: str,
    block_edge: str,
) -> None:
    """Uma faixa pre-renderizada, um desenho por frame (R27.2).

    A v2 refazia a mesma parede de blocos a cada frame — 22 blits num canvas de
    480 de largura, e mais quanto maior a tela — para um resultado que so difere
    entre frames por alguns pixels de deslocamento horizontal. Aqui a parede e
    construida uma vez por par de blocos e o rolamento vira o retangulo de
    origem andando dentro dela.

    Continua usando as texturas do bioma atual, sem congelamento (R7.3): o par de
    blocos entra na chave da faixa, entao trocar de bioma pede outra faixa em vez
    de reaproveitar a anterior.
    """
    top_y = config.ground_y()
    canvas_w = config.screen_w()
    height = config.screen_h() - top_y
    strip = renderer.image(
        ("ground", block_main, block_edge, canvas_w, height),
        lambda: _make_strip(textures[block_main], textures[block_edge], canvas_w, height),
    )
    # a faixa e periodica em BLOCK, entao deslocar a origem de 0 a BLOCK cobre o
    # ciclo inteiro de rolamento; `offset` ja vem reduzido modulo BLOCK.
    renderer.draw(strip, (0, top_y), pygame.Rect(BLOCK - round(self.offset), 0, canvas_w, height))
sync_rect()

Reancora a faixa de colisao na linha do chao e na largura do canvas atuais.

Nao e chamada no passo de simulacao, ao contrario das irmas em Bird e PipePair: a geometria do chao nao depende de nada que role durante a partida, so do canvas. Quem chama e Game.apply_resize. Um sync_rect() dentro de update seria codigo que nenhum teste consegue justificar — foi escrito, sobreviveu a rodada de mutacao por ser inerte, e saiu.

Source code in src/ground.py
22
23
24
25
26
27
28
29
30
31
32
def sync_rect(self) -> None:
    """Reancora a faixa de colisao na linha do chao e na largura do canvas atuais.

    Nao e chamada no passo de simulacao, ao contrario das irmas em `Bird` e
    `PipePair`: a geometria do chao nao depende de nada que role durante a partida,
    so do canvas. Quem chama e `Game.apply_resize`. Um `sync_rect()` dentro de
    `update` seria codigo que nenhum teste consegue justificar — foi escrito,
    sobreviveu a rodada de mutacao por ser inerte, e saiu.
    """
    self.rect.y = config.ground_y()
    self.rect.width = config.screen_w()
update(speed)

Avanca o deslocamento visual do chao pela velocidade atual.

Source code in src/ground.py
18
19
20
def update(self, speed: float) -> None:
    """Avanca o deslocamento visual do chao pela velocidade atual."""
    self.offset = (self.offset - speed) % BLOCK

src.input

src.input

InputManager: unifica teclado, mouse, toque e controle Xbox em acoes abstratas (R10, R15).

CONSUMED_EVENTS = (pygame.QUIT, *FOCUS_LOST_EVENTS, pygame.WINDOWRESIZED, pygame.KEYDOWN, pygame.MOUSEBUTTONDOWN, pygame.FINGERDOWN, pygame.JOYBUTTONDOWN, pygame.JOYDEVICEADDED, pygame.JOYDEVICEREMOVED) module-attribute

Todo tipo de evento que poll reconhece — e, por configure_event_filter, todo tipo que chega a existir como objeto Python.

E a mesma lista para as duas coisas de proposito: um tipo tratado no poll mas esquecido aqui seria filtrado antes de chegar la, e o bug apareceria como uma acao que simplesmente parou de funcionar.

InputManager

Traduz teclado, mouse, toque e joystick em acoes abstratas do jogo.

Source code in src/input.py
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
class InputManager:
    """Traduz teclado, mouse, toque e joystick em acoes abstratas do jogo."""

    def __init__(self, renderer: render.Renderer) -> None:
        """Detecta e guarda os joysticks ja conectados na inicializacao."""
        self.renderer = renderer
        pygame.joystick.init()
        self.joysticks: dict[int, pygame.joystick.JoystickType] = {}
        for device_index in range(pygame.joystick.get_count()):
            self._add_joystick(device_index)

    def _add_joystick(self, device_index: int) -> None:
        joystick = pygame.joystick.Joystick(device_index)
        self.joysticks[joystick.get_instance_id()] = joystick

    def _remove_joystick(self, instance_id: int) -> None:
        self.joysticks.pop(instance_id, None)

    def _tap(self, actions: set[str], window_x: float, window_y: float) -> None:
        """Converte um ponto da janela para o canvas e resolve a acao.

        Mouse e toque passam pelo mesmo `to_logical` do renderizador ativo, sem
        nenhuma regra divergente entre eles (R34.2). Na v2 o tratamento era
        assimetrico — `pygame.SCALED` pre-convertia o mouse e so o toque era
        convertido a mao —, e a assimetria desapareceu junto com o `SCALED` (design
        secao 33.4).
        """
        self._handle_tap(actions, *self.renderer.to_logical(window_x, window_y))

    def _handle_tap(self, actions: set[str], lx: float, ly: float) -> None:
        """Toque/clique em qualquer ponto do canvas voa; no icone de mudo alterna mudo.

        Fora do canvas e ignorado (R34.1, R34.3, R15.1, R15.4).

        O recorte e contra o **canvas**, e nao contra a area jogavel. Na v2 o que
        caisse fora dos 480x720 era barra preta de letterbox, moldura morta do SDL, e
        descartar era certo; na v3 aquele espaco e faixa decorativa desenhada e, num
        celular 20:9, ocupa mais tela que o jogo — justamente onde o polegar cai
        (R25.6, design secao 32.8). Como o canvas tem a proporcao da tela e a cobre
        por construcao, o descarte de R34.3 so sobra para o arredondamento da
        conversao.
        """
        if not (0 <= lx <= config.screen_w() and 0 <= ly <= config.screen_h()):
            return
        if ui.mute_icon_rect().collidepoint(lx, ly):
            actions.add(ACTION_MUTE)
        else:
            actions.add(ACTION_FLAP)

    def poll(self) -> tuple[set[str], bool]:
        """Consome a fila de eventos e retorna (acoes abstratas, pedido de fechar janela)."""
        actions: set[str] = set()
        quit_requested = False

        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                quit_requested = True
            elif event.type in FOCUS_LOST_EVENTS:
                actions.add(ACTION_FOCUS_LOST)
            elif event.type == pygame.WINDOWRESIZED:
                # so acontece no desktop: no Android a janela e a tela inteira e a
                # orientacao esta travada (R23.5, R23.6).
                actions.add(ACTION_RESIZE)
            elif event.type == pygame.KEYDOWN:
                if event.key in FLAP_KEYS:
                    actions.add(ACTION_FLAP)
                elif event.key in PAUSE_KEYS:
                    actions.add(ACTION_PAUSE)
                elif event.key in MUTE_KEYS:
                    actions.add(ACTION_MUTE)
                elif event.key in BACK_KEYS:
                    actions.add(ACTION_BACK)
                elif event.key in LEFT_KEYS:
                    actions.add(ACTION_LEFT)
                elif event.key in RIGHT_KEYS:
                    actions.add(ACTION_RIGHT)
            elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
                # `event.pos` vem em pixels reais da janela
                self._tap(actions, *event.pos)
            elif event.type == pygame.FINGERDOWN:
                # o toque chega normalizado (0.0-1.0) sobre a janela inteira
                win_w, win_h = self.renderer.window_size
                self._tap(actions, event.x * win_w, event.y * win_h)
            elif event.type == pygame.JOYBUTTONDOWN:
                if event.button == BUTTON_A:
                    actions.add(ACTION_FLAP)
                elif event.button == BUTTON_START:
                    actions.add(ACTION_PAUSE)
                elif event.button == BUTTON_Y:
                    actions.add(ACTION_MUTE)
            elif event.type == pygame.JOYDEVICEADDED:
                self._add_joystick(event.device_index)
            elif event.type == pygame.JOYDEVICEREMOVED:
                self._remove_joystick(event.instance_id)

        return actions, quit_requested
__init__(renderer)

Detecta e guarda os joysticks ja conectados na inicializacao.

Source code in src/input.py
73
74
75
76
77
78
79
def __init__(self, renderer: render.Renderer) -> None:
    """Detecta e guarda os joysticks ja conectados na inicializacao."""
    self.renderer = renderer
    pygame.joystick.init()
    self.joysticks: dict[int, pygame.joystick.JoystickType] = {}
    for device_index in range(pygame.joystick.get_count()):
        self._add_joystick(device_index)
poll()

Consome a fila de eventos e retorna (acoes abstratas, pedido de fechar janela).

Source code in src/input.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
def poll(self) -> tuple[set[str], bool]:
    """Consome a fila de eventos e retorna (acoes abstratas, pedido de fechar janela)."""
    actions: set[str] = set()
    quit_requested = False

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            quit_requested = True
        elif event.type in FOCUS_LOST_EVENTS:
            actions.add(ACTION_FOCUS_LOST)
        elif event.type == pygame.WINDOWRESIZED:
            # so acontece no desktop: no Android a janela e a tela inteira e a
            # orientacao esta travada (R23.5, R23.6).
            actions.add(ACTION_RESIZE)
        elif event.type == pygame.KEYDOWN:
            if event.key in FLAP_KEYS:
                actions.add(ACTION_FLAP)
            elif event.key in PAUSE_KEYS:
                actions.add(ACTION_PAUSE)
            elif event.key in MUTE_KEYS:
                actions.add(ACTION_MUTE)
            elif event.key in BACK_KEYS:
                actions.add(ACTION_BACK)
            elif event.key in LEFT_KEYS:
                actions.add(ACTION_LEFT)
            elif event.key in RIGHT_KEYS:
                actions.add(ACTION_RIGHT)
        elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            # `event.pos` vem em pixels reais da janela
            self._tap(actions, *event.pos)
        elif event.type == pygame.FINGERDOWN:
            # o toque chega normalizado (0.0-1.0) sobre a janela inteira
            win_w, win_h = self.renderer.window_size
            self._tap(actions, event.x * win_w, event.y * win_h)
        elif event.type == pygame.JOYBUTTONDOWN:
            if event.button == BUTTON_A:
                actions.add(ACTION_FLAP)
            elif event.button == BUTTON_START:
                actions.add(ACTION_PAUSE)
            elif event.button == BUTTON_Y:
                actions.add(ACTION_MUTE)
        elif event.type == pygame.JOYDEVICEADDED:
            self._add_joystick(event.device_index)
        elif event.type == pygame.JOYDEVICEREMOVED:
            self._remove_joystick(event.instance_id)

    return actions, quit_requested

configure_event_filter()

Deixa entrar na fila so o que o jogo consome (R27.6). Chamar apos pygame.init().

Lista de permissao, e nao de bloqueio: bloquear nominalmente FINGERMOTION e MOUSEMOTION resolveria o caso conhecido de hoje e deixaria o de amanha passar.

O caso que motiva a regra e o FINGERMOTION. Enquanto o dedo esta na tela, o Android o emite na taxa de amostragem do digitalizador — 120 a 240 Hz em aparelhos correntes —, e cada evento vira um objeto Python que e criado, percorrido no laco de poll e descartado, tres a quatro vezes por frame, para nao virar acao nenhuma. MOUSEMOTION tem o mesmo perfil no desktop. Sao alocacoes por frame no caminho quente, que e o que a secao 36 do design esta eliminando (R27.3).

Bloquear e mais forte que ignorar: o SDL descarta o evento antes de ele virar objeto — pygame.event.post de um tipo bloqueado sequer entra na fila.

Source code in src/input.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def configure_event_filter() -> None:
    """Deixa entrar na fila so o que o jogo consome (R27.6). Chamar apos `pygame.init()`.

    Lista de permissao, e nao de bloqueio: bloquear nominalmente `FINGERMOTION` e
    `MOUSEMOTION` resolveria o caso conhecido de hoje e deixaria o de amanha passar.

    O caso que motiva a regra e o `FINGERMOTION`. Enquanto o dedo esta na tela, o
    Android o emite na taxa de amostragem do digitalizador — 120 a 240 Hz em aparelhos
    correntes —, e cada evento vira um objeto Python que e criado, percorrido no laco
    de `poll` e descartado, tres a quatro vezes por frame, para nao virar acao nenhuma.
    `MOUSEMOTION` tem o mesmo perfil no desktop. Sao alocacoes por frame no caminho
    quente, que e o que a secao 36 do design esta eliminando (R27.3).

    Bloquear e mais forte que ignorar: o SDL descarta o evento antes de ele virar
    objeto — `pygame.event.post` de um tipo bloqueado sequer entra na fila.
    """
    pygame.event.set_blocked(None)  # None = todos
    pygame.event.set_allowed(CONSUMED_EVENTS)

src.mobs

src.mobs

Mobs decorativos das faixas (R25.3, R25.4).

Nove mobs voxel — creeper, bruxa e aldeao no Overworld; enderman, aranha e esqueleto na Cave; ghast, blaze e piglin no Nether — habitam exclusivamente as faixas decorativas: a de chao estendida, abaixo da area jogavel, e as laterais, o corte transversal do subsolo. Nunca a area jogavel. Isso nao e disciplina de quem desenha: cada mob e recortado contra o retangulo da faixa antes de ir para a tela, e as faixas sao disjuntas de viewport.play por construcao (viewport.py), entao um pixel de mob dentro do campo de jogo nao tem por onde acontecer.

O recorte tambem e o que faz a lateral parecer um poco de mineracao de verdade: o mob some devagar atras da parede de terra em vez de piscar ao cruzar a borda.

R25.4 e a restricao que importa, e este cabecalho de imports e a prova dela. mobs.py nao importa game, bird, pipes, score nem biome — recebe o id do bioma, um deslocamento e o Viewport ativo, e mais nada. Nao ha caminho pelo qual um mob colida, pontue ou mude velocidade, porque nao ha caminho pelo qual ele alcance qualquer uma dessas coisas. E uma dependencia de mao unica, e e o que torna o requisito verificavel por teste em vez de por inspecao.

Ver specs/v3/design.md secao 35.

BIOME_MOBS = {'overworld': ('creeper', 'witch', 'villager'), 'cave': ('enderman', 'spider', 'skeleton'), 'nether': ('ghast', 'blaze', 'piglin')} module-attribute

Tres variedades por bioma (R25.3), chaveadas pelo id do bioma — o mesmo padrao de bands.DEEP_BLOCK e de decor._LAYERS, para que a decoracao nao vire campo de Biome.

FIELD_SPANS = 3 module-attribute

Quantas larguras de canvas um campo cobre antes de se repetir, no minimo.

Mesmo acordo entre memoria e repeticao das faixas de parallax (decor.STRIP_SPANS), so que aqui o custo e ainda menor: um campo e uma tupla de posicoes, nao pixels.

IDLE_FRAME_INTERVAL = 6 module-attribute

Frames entre os dois quadros de idle: a mesma cadencia da asa da abelha (bird.WING_FRAME_INTERVAL).

Repetida aqui em vez de importada, e a repeticao e o ponto: mobs.py nao depende de nenhum modulo de jogo (R25.4), e uma constante de seis linhas nao vale abrir a primeira excecao a isso.

MIN_SLOTS = 6 module-attribute

Piso de posicoes por campo, para que as tres variedades do bioma (R25.3) aparecam mesmo num canvas estreito, onde a conta de FIELD_SPANS daria menos que isso.

MOB_FACTOR = 0.15 module-attribute

Deriva propria dos mobs, como fracao da velocidade do bioma.

Metade da camada distante do parallax (decor.FAR_FACTOR, 0.3) — quanto mais lento, mais longe. E o que os coloca claramente atras do cenario e o que impede que algo no fundo compita com a coluna pela atencao do jogador.

MOB_SIZE = 32 module-attribute

Lado do sprite desenhado, em pixels de canvas.

O dobro exato dos 16x16 de origem: escala inteira, sem pixel de largura desigual, o mesmo criterio com que textures.scale_pixel_perfect amplia os blocos. Menor que os 48 da abelha de proposito — o mob e cenario, e nao pode ser confundido com o jogador.

SIDE_PERIOD = 160 module-attribute

Distancia horizontal entre dois mobs vizinhos do mesmo campo.

Sao os unicos numeros de espacamento: o desvio sorteado dentro de cada periodo nunca passa de period - MOB_SIZE, entao dois vizinhos ficam sempre a pelo menos um sprite de distancia e nenhum par se sobrepoe — nem na emenda do ciclo.

MobField

Os mobs em cena: deriva propria, idle proprio, e nenhuma outra informacao.

Uma instancia por partida, como DecorManager. O estado inteiro sao dois numeros — o quanto o campo ja derivou e em que quadro de idle ele esta.

Source code in src/mobs.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
class MobField:
    """Os mobs em cena: deriva propria, idle proprio, e nenhuma outra informacao.

    Uma instancia por partida, como `DecorManager`. O estado inteiro sao dois numeros —
    o quanto o campo ja derivou e em que quadro de idle ele esta.
    """

    def __init__(self) -> None:
        """Inicia sem deriva, no primeiro quadro de idle."""
        self.scrolled = 0.0
        self.frame = 0
        self._frame_timer = 0

    def update(self, speed: float) -> None:
        """Avanca a deriva e a animacao de idle.

        `speed` zero congela a deriva e deixa o idle correndo, que e o que a tela
        PRONTO pede: o cenario respira antes de a partida comecar, sem sair do lugar.
        """
        self.scrolled += speed * MOB_FACTOR
        self._frame_timer += 1
        if self._frame_timer >= IDLE_FRAME_INTERVAL:
            self._frame_timer = 0
            self.frame = 1 - self.frame

    def draw_ground(self, renderer: render.Renderer, biome_id: str) -> None:
        """Mobs da faixa de chao estendida. Desenhados depois do chao (design secao 32.6)."""
        band = config.viewport().ground_band
        self._draw(renderer, biome_id, "ground", GROUND_PERIOD, band.top, band.height, (band,))

    def draw_sides(self, renderer: render.Renderer, biome_id: str) -> None:
        """Mobs das faixas laterais. Desenhados **depois** das faixas, que sao opacas.

        Um unico campo alimenta os dois lados, e nao um por lado: assim o mob que sai
        pela esquerda da faixa direita e o mesmo que, muito depois, entra pela direita
        da esquerda — como se tivesse atravessado a terra por tras do poco.
        """
        vp = config.viewport()
        self._draw(renderer, biome_id, "side", SIDE_PERIOD, 0, vp.height, (vp.left_band, vp.right_band))

    def _draw(
        self,
        renderer: render.Renderer,
        biome_id: str,
        band: str,
        period: int,
        top: int,
        height: int,
        clips: tuple[pygame.Rect, ...],
    ) -> None:
        """Desenha o campo recortado contra as faixas `clips`.

        A faixa que nao existe tem largura ou altura zero (tela 2:3) e cai fora aqui —
        nada e sorteado e nada e desenhado. A faixa mais baixa que um sprite tambem: um
        mob cortado ao meio na horizontal, contra o ceu, leria como defeito, ao
        contrario do corte vertical da lateral, que le como estar atras da terra.
        """
        visible_clips = [clip for clip in clips if clip.width > 0 and clip.height > 0]
        if not visible_clips or height < MOB_SIZE:
            return

        canvas_w = renderer.size[0]
        field = _field(biome_id, band, period, top, height, canvas_w)
        span = len(field) * period
        offset = round(self.scrolled) % span

        for slot in field:
            x = (slot.x - offset) % span
            if x >= canvas_w:
                # alem da borda direita do canvas so pode aparecer pelo outro lado: e o
                # mob que ja deu a volta e esta entrando com parte do corpo de fora.
                x -= span
            rect = pygame.Rect(x, slot.y, MOB_SIZE, MOB_SIZE)
            image: render.Image | None = None
            for clip in visible_clips:
                shown = rect.clip(clip)
                if not shown.width or not shown.height:
                    continue
                if image is None:  # so paga a consulta quando ha mesmo o que desenhar
                    image = _sprite(renderer, slot.kind, (self.frame + slot.phase) % len(IDLE_FRAMES))
                area = pygame.Rect(shown.x - rect.x, shown.y - rect.y, shown.width, shown.height)
                renderer.draw(image, shown.topleft, area)
__init__()

Inicia sem deriva, no primeiro quadro de idle.

Source code in src/mobs.py
160
161
162
163
164
def __init__(self) -> None:
    """Inicia sem deriva, no primeiro quadro de idle."""
    self.scrolled = 0.0
    self.frame = 0
    self._frame_timer = 0
draw_ground(renderer, biome_id)

Mobs da faixa de chao estendida. Desenhados depois do chao (design secao 32.6).

Source code in src/mobs.py
178
179
180
181
def draw_ground(self, renderer: render.Renderer, biome_id: str) -> None:
    """Mobs da faixa de chao estendida. Desenhados depois do chao (design secao 32.6)."""
    band = config.viewport().ground_band
    self._draw(renderer, biome_id, "ground", GROUND_PERIOD, band.top, band.height, (band,))
draw_sides(renderer, biome_id)

Mobs das faixas laterais. Desenhados depois das faixas, que sao opacas.

Um unico campo alimenta os dois lados, e nao um por lado: assim o mob que sai pela esquerda da faixa direita e o mesmo que, muito depois, entra pela direita da esquerda — como se tivesse atravessado a terra por tras do poco.

Source code in src/mobs.py
183
184
185
186
187
188
189
190
191
def draw_sides(self, renderer: render.Renderer, biome_id: str) -> None:
    """Mobs das faixas laterais. Desenhados **depois** das faixas, que sao opacas.

    Um unico campo alimenta os dois lados, e nao um por lado: assim o mob que sai
    pela esquerda da faixa direita e o mesmo que, muito depois, entra pela direita
    da esquerda — como se tivesse atravessado a terra por tras do poco.
    """
    vp = config.viewport()
    self._draw(renderer, biome_id, "side", SIDE_PERIOD, 0, vp.height, (vp.left_band, vp.right_band))
update(speed)

Avanca a deriva e a animacao de idle.

speed zero congela a deriva e deixa o idle correndo, que e o que a tela PRONTO pede: o cenario respira antes de a partida comecar, sem sair do lugar.

Source code in src/mobs.py
166
167
168
169
170
171
172
173
174
175
176
def update(self, speed: float) -> None:
    """Avanca a deriva e a animacao de idle.

    `speed` zero congela a deriva e deixa o idle correndo, que e o que a tela
    PRONTO pede: o cenario respira antes de a partida comecar, sem sair do lugar.
    """
    self.scrolled += speed * MOB_FACTOR
    self._frame_timer += 1
    if self._frame_timer >= IDLE_FRAME_INTERVAL:
        self._frame_timer = 0
        self.frame = 1 - self.frame

precompute(renderer)

Constroi os 18 sprites de uma vez, fora do frame.

Chamada na inicializacao e de novo apos um redimensionamento, que descarta o cache de imagens do renderizador — mesma disciplina de bird.precompute_sprites. Os sprites nao dependem do canvas, mas forget_images nao sabe distinguir isso.

Source code in src/mobs.py
111
112
113
114
115
116
117
118
119
120
def precompute(renderer: render.Renderer) -> None:
    """Constroi os 18 sprites de uma vez, fora do frame.

    Chamada na inicializacao e de novo apos um redimensionamento, que descarta o cache
    de imagens do renderizador — mesma disciplina de `bird.precompute_sprites`. Os
    sprites nao dependem do canvas, mas `forget_images` nao sabe distinguir isso.
    """
    for kind in textures.MOB_MAKERS:
        for frame in IDLE_FRAMES:
            _sprite(renderer, kind, frame)

src.particles

src.particles

Sistema de particulas de bloco quebrando na colisao (R3.2).

Particle

Um fragmento de bloco em queda livre, com cor amostrada da textura atingida.

Sem __dict__ por instancia: sao 16 por explosao, todas iguais em forma (R27.3).

Source code in src/particles.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class Particle:
    """Um fragmento de bloco em queda livre, com cor amostrada da textura atingida.

    Sem `__dict__` por instancia: sao 16 por explosao, todas iguais em forma (R27.3).
    """

    __slots__ = ("color", "lifetime", "pos", "rect", "size", "vel")

    def __init__(
        self,
        pos: tuple[float, float],
        vel: tuple[float, float],
        lifetime: int,
        size: int,
        color: tuple[int, int, int],
    ) -> None:
        """Cria a particula em `pos`, com velocidade, duracao, tamanho e cor dados."""
        self.pos = pygame.Vector2(pos)
        self.vel = pygame.Vector2(vel)
        self.lifetime = lifetime
        self.size = size
        self.color = color
        self.rect = pygame.Rect(0, 0, size, size)
        """Retangulo de desenho persistente, pelo mesmo motivo da hitbox da abelha:
        `draw` roda uma vez por particula por frame, e eram 16 `Rect` novos por frame
        na tela de GAME_OVER (R27.3)."""
        self._sync_rect()

    def update(self) -> None:
        """Avanca um passo: gravidade, posicao, contagem regressiva de vida."""
        self.vel.y += GRAVITY
        self.pos += self.vel
        self.lifetime -= 1
        self._sync_rect()

    def _sync_rect(self) -> None:
        self.rect.centerx = round(self.pos.x)
        self.rect.centery = round(self.pos.y)

    @property
    def alive(self) -> bool:
        """Verdadeiro enquanto a duracao restante for maior que zero."""
        return self.lifetime > 0

    def draw(self, renderer: render.Renderer) -> None:
        """Desenha a particula como um quadrado solido na sua cor."""
        renderer.fill(self.color, self.rect)
alive property

Verdadeiro enquanto a duracao restante for maior que zero.

rect = pygame.Rect(0, 0, size, size) instance-attribute

Retangulo de desenho persistente, pelo mesmo motivo da hitbox da abelha: draw roda uma vez por particula por frame, e eram 16 Rect novos por frame na tela de GAME_OVER (R27.3).

__init__(pos, vel, lifetime, size, color)

Cria a particula em pos, com velocidade, duracao, tamanho e cor dados.

Source code in src/particles.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def __init__(
    self,
    pos: tuple[float, float],
    vel: tuple[float, float],
    lifetime: int,
    size: int,
    color: tuple[int, int, int],
) -> None:
    """Cria a particula em `pos`, com velocidade, duracao, tamanho e cor dados."""
    self.pos = pygame.Vector2(pos)
    self.vel = pygame.Vector2(vel)
    self.lifetime = lifetime
    self.size = size
    self.color = color
    self.rect = pygame.Rect(0, 0, size, size)
    """Retangulo de desenho persistente, pelo mesmo motivo da hitbox da abelha:
    `draw` roda uma vez por particula por frame, e eram 16 `Rect` novos por frame
    na tela de GAME_OVER (R27.3)."""
    self._sync_rect()
draw(renderer)

Desenha a particula como um quadrado solido na sua cor.

Source code in src/particles.py
63
64
65
def draw(self, renderer: render.Renderer) -> None:
    """Desenha a particula como um quadrado solido na sua cor."""
    renderer.fill(self.color, self.rect)
update()

Avanca um passo: gravidade, posicao, contagem regressiva de vida.

Source code in src/particles.py
47
48
49
50
51
52
def update(self) -> None:
    """Avanca um passo: gravidade, posicao, contagem regressiva de vida."""
    self.vel.y += GRAVITY
    self.pos += self.vel
    self.lifetime -= 1
    self._sync_rect()

ParticleSystem

Conjunto de particulas vivas: dispara explosoes e atualiza/desenha todas.

Source code in src/particles.py
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
class ParticleSystem:
    """Conjunto de particulas vivas: dispara explosoes e atualiza/desenha todas."""

    def __init__(self) -> None:
        """Inicia sem nenhuma particula em cena."""
        self.particles: list[Particle] = []

    def burst(self, pos: tuple[float, float], texture: pygame.Surface, n: int = 16) -> None:
        """Amostra cores da textura do bloco atingido e dispara explosao radial (R3.2)."""
        w, h = texture.get_size()
        for _ in range(n):
            r, g, b = texture.get_at((random.randrange(w), random.randrange(h)))[:3]
            color = (r, g, b)

            angle = random.uniform(0, math.tau)
            speed = random.uniform(MIN_SPEED, MAX_SPEED)
            vel = (math.cos(angle) * speed, math.sin(angle) * speed)

            lifetime = random.randint(MIN_LIFETIME, MAX_LIFETIME)
            size = random.randint(MIN_SIZE, MAX_SIZE)
            self.particles.append(Particle(pos, vel, lifetime, size, color))

    def update(self) -> None:
        """Atualiza todas as particulas e remove as que ja expiraram."""
        for particle in self.particles:
            particle.update()
        # remocao no lugar, como em `PipeManager.update`: sem particula em cena o laco
        # nao toca em nada, e a lista vazia sobrevive de frame a frame (R27.3).
        index = 0
        while index < len(self.particles):
            if self.particles[index].alive:
                index += 1
            else:
                del self.particles[index]

    def draw(self, renderer: render.Renderer) -> None:
        """Desenha todas as particulas vivas."""
        for particle in self.particles:
            particle.draw(renderer)
__init__()

Inicia sem nenhuma particula em cena.

Source code in src/particles.py
71
72
73
def __init__(self) -> None:
    """Inicia sem nenhuma particula em cena."""
    self.particles: list[Particle] = []
burst(pos, texture, n=16)

Amostra cores da textura do bloco atingido e dispara explosao radial (R3.2).

Source code in src/particles.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def burst(self, pos: tuple[float, float], texture: pygame.Surface, n: int = 16) -> None:
    """Amostra cores da textura do bloco atingido e dispara explosao radial (R3.2)."""
    w, h = texture.get_size()
    for _ in range(n):
        r, g, b = texture.get_at((random.randrange(w), random.randrange(h)))[:3]
        color = (r, g, b)

        angle = random.uniform(0, math.tau)
        speed = random.uniform(MIN_SPEED, MAX_SPEED)
        vel = (math.cos(angle) * speed, math.sin(angle) * speed)

        lifetime = random.randint(MIN_LIFETIME, MAX_LIFETIME)
        size = random.randint(MIN_SIZE, MAX_SIZE)
        self.particles.append(Particle(pos, vel, lifetime, size, color))
draw(renderer)

Desenha todas as particulas vivas.

Source code in src/particles.py
103
104
105
106
def draw(self, renderer: render.Renderer) -> None:
    """Desenha todas as particulas vivas."""
    for particle in self.particles:
        particle.draw(renderer)
update()

Atualiza todas as particulas e remove as que ja expiraram.

Source code in src/particles.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def update(self) -> None:
    """Atualiza todas as particulas e remove as que ja expiraram."""
    for particle in self.particles:
        particle.update()
    # remocao no lugar, como em `PipeManager.update`: sem particula em cena o laco
    # nao toca em nada, e a lista vazia sobrevive de frame a frame (R27.3).
    index = 0
    while index < len(self.particles):
        if self.particles[index].alive:
            index += 1
        else:
            del self.particles[index]

src.perf

src.perf

Instrumentacao de desempenho: medicao por frame e sobreposicao de diagnostico (R30).

Desligada por padrao. So e ligada quando a variavel de ambiente BLOCKY_PERF esta definida com um valor diferente de vazio e de "0" — assim o custo em producao e uma comparacao contra None por frame, e nenhuma medicao entra no caminho quente (R30.2).

O ponto de medir e nao otimizar no escuro: os numeros produzidos aqui (no aparelho) e por scripts/benchmark.py (no CI) sao o que prova que cada task de otimizacao da v3 funcionou, em vez de supor. Ver specs/v3/design.md secao 39.

GC_THRESHOLD = (50000, 50, 50) module-attribute

Limiares do coletor ciclico depois de tune_gc(), contra os (700, 10, 10) padrao.

Setenta vezes mais alocacoes liquidas de contentor entre varreduras da geracao 0. Com a disciplina de alocacao da task 58 no lugar, o laco em regime permanente produz um saldo liquido proximo de zero, entao o limiar alto adia a varredura para muito alem da duracao de uma partida.

WINDOW_FRAMES = 60 module-attribute

Tamanho da janela da media movel: ~1 segundo a 60 FPS.

FrameProfiler

Acumula tempo de atualizacao, tempo de desenho e duracao do frame (R30.1).

O uso previsto por frame e: frame(ms) com o retorno de Clock.tick(), depois begin() antes de update(), end_update() logo apos, e end_draw() apos o desenho. Cada end_* mede desde a ultima marcacao e reinicia o cronometro, entao nao ha necessidade de chamar begin() de novo entre as duas etapas.

Source code in src/perf.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
class FrameProfiler:
    """Acumula tempo de atualizacao, tempo de desenho e duracao do frame (R30.1).

    O uso previsto por frame e: `frame(ms)` com o retorno de `Clock.tick()`, depois
    `begin()` antes de `update()`, `end_update()` logo apos, e `end_draw()` apos o
    desenho. Cada `end_*` mede desde a ultima marcacao e reinicia o cronometro, entao
    nao ha necessidade de chamar `begin()` de novo entre as duas etapas.
    """

    __slots__ = ("_draw_ms", "_frame_ms", "_mark", "_update_ms", "backend")

    def __init__(self, window: int = WINDOW_FRAMES) -> None:
        """Cria o profiler com janelas de media movel de `window` frames."""
        self._update_ms = _MovingAverage(window)
        self._draw_ms = _MovingAverage(window)
        self._frame_ms = _MovingAverage(window)
        self._mark = 0.0
        self.backend = "surface"
        """Caminho de renderizacao em uso, preenchido pela camada de render (R26.5)."""

    def frame(self, frame_ms: float) -> None:
        """Registra a duracao real do frame, tal como reportada pelo relogio do pygame."""
        self._frame_ms.add(frame_ms)

    def begin(self) -> None:
        """Inicia a cronometragem de uma etapa do frame."""
        self._mark = time.perf_counter()

    def end_update(self) -> None:
        """Fecha a medicao da atualizacao e reinicia o cronometro para o desenho."""
        self._update_ms.add(self._split_ms())

    def end_draw(self) -> None:
        """Fecha a medicao do desenho."""
        self._draw_ms.add(self._split_ms())

    def _split_ms(self) -> float:
        """Devolve os milissegundos desde a ultima marcacao e remarca o cronometro."""
        now = time.perf_counter()
        elapsed = (now - self._mark) * 1000.0
        self._mark = now
        return elapsed

    @property
    def update_ms(self) -> float:
        """Tempo medio de `update()` na janela, em milissegundos."""
        return self._update_ms.average

    @property
    def draw_ms(self) -> float:
        """Tempo medio de desenho na janela, em milissegundos."""
        return self._draw_ms.average

    @property
    def fps(self) -> float:
        """Taxa de quadros media derivada da duracao real dos frames."""
        average = self._frame_ms.average
        return 1000.0 / average if average > 0 else 0.0

    def lines(self) -> list[str]:
        """Monta as linhas da sobreposicao, em maiusculas (a fonte bitmap nao tem minusculas)."""
        return [
            f"FPS {self.fps:.0f}",
            f"UPD {self.update_ms:.1f}MS",
            f"DRW {self.draw_ms:.1f}MS",
            self.backend.upper(),
        ]
backend = 'surface' instance-attribute

Caminho de renderizacao em uso, preenchido pela camada de render (R26.5).

draw_ms property

Tempo medio de desenho na janela, em milissegundos.

fps property

Taxa de quadros media derivada da duracao real dos frames.

update_ms property

Tempo medio de update() na janela, em milissegundos.

__init__(window=WINDOW_FRAMES)

Cria o profiler com janelas de media movel de window frames.

Source code in src/perf.py
111
112
113
114
115
116
117
118
def __init__(self, window: int = WINDOW_FRAMES) -> None:
    """Cria o profiler com janelas de media movel de `window` frames."""
    self._update_ms = _MovingAverage(window)
    self._draw_ms = _MovingAverage(window)
    self._frame_ms = _MovingAverage(window)
    self._mark = 0.0
    self.backend = "surface"
    """Caminho de renderizacao em uso, preenchido pela camada de render (R26.5)."""
begin()

Inicia a cronometragem de uma etapa do frame.

Source code in src/perf.py
124
125
126
def begin(self) -> None:
    """Inicia a cronometragem de uma etapa do frame."""
    self._mark = time.perf_counter()
end_draw()

Fecha a medicao do desenho.

Source code in src/perf.py
132
133
134
def end_draw(self) -> None:
    """Fecha a medicao do desenho."""
    self._draw_ms.add(self._split_ms())
end_update()

Fecha a medicao da atualizacao e reinicia o cronometro para o desenho.

Source code in src/perf.py
128
129
130
def end_update(self) -> None:
    """Fecha a medicao da atualizacao e reinicia o cronometro para o desenho."""
    self._update_ms.add(self._split_ms())
frame(frame_ms)

Registra a duracao real do frame, tal como reportada pelo relogio do pygame.

Source code in src/perf.py
120
121
122
def frame(self, frame_ms: float) -> None:
    """Registra a duracao real do frame, tal como reportada pelo relogio do pygame."""
    self._frame_ms.add(frame_ms)
lines()

Monta as linhas da sobreposicao, em maiusculas (a fonte bitmap nao tem minusculas).

Source code in src/perf.py
159
160
161
162
163
164
165
166
def lines(self) -> list[str]:
    """Monta as linhas da sobreposicao, em maiusculas (a fonte bitmap nao tem minusculas)."""
    return [
        f"FPS {self.fps:.0f}",
        f"UPD {self.update_ms:.1f}MS",
        f"DRW {self.draw_ms:.1f}MS",
        self.backend.upper(),
    ]

draw_overlay(renderer, profiler)

Desenha a sobreposicao de diagnostico no canto superior esquerdo (R30.1).

Usa a fonte bitmap propria (pixelfont), guardada como imagem por (texto, escala, cor) — entao so o primeiro frame de cada valor distinto paga a renderizacao dos glifos, e o custo da sobreposicao nao mascara o que ela esta medindo.

Source code in src/perf.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def draw_overlay(renderer: render.Renderer, profiler: FrameProfiler) -> None:
    """Desenha a sobreposicao de diagnostico no canto superior esquerdo (R30.1).

    Usa a fonte bitmap propria (`pixelfont`), guardada como imagem por (texto, escala,
    cor) — entao so o primeiro frame de cada valor distinto paga a renderizacao dos
    glifos, e o custo da sobreposicao nao mascara o que ela esta medindo.
    """
    y = _OVERLAY_MARGIN
    for line in profiler.lines():
        shadow = _glyphs(renderer, line, _OVERLAY_SHADOW)
        main = _glyphs(renderer, line, _OVERLAY_COLOR)
        renderer.draw(shadow, (_OVERLAY_MARGIN + 1, y + 1))
        renderer.draw(main, (_OVERLAY_MARGIN, y))
        y += pixelfont.GLYPH_H * _OVERLAY_SCALE + _LINE_SPACING

enabled()

Indica se a instrumentacao foi ligada por variavel de ambiente (R30.2).

Source code in src/perf.py
37
38
39
def enabled() -> bool:
    """Indica se a instrumentacao foi ligada por variavel de ambiente (R30.2)."""
    return os.environ.get(ENV_VAR, "") not in ("", "0")

tune_gc()

Tira o coletor ciclico do caminho do frame (R27.3). Chamar ao fim da inicializacao.

Sao tres passos, nessa ordem:

  1. gc.collect() — recolhe o lixo da propria inicializacao antes de congelar, para nao congelar justamente o que deveria ser jogado fora.
  2. gc.freeze() — move tudo que sobrou para a geracao permanente, que o coletor nao percorre. Quase todo o jogo e construido aqui e vive ate o fim (texturas, atlas, glifos, definicoes de bioma), entao isso remove de uma vez a maior parte do trabalho de qualquer varredura futura.
  3. gc.set_threshold(*GC_THRESHOLD) — o que ainda for varrido, sera muito mais raro.

gc.disable() foi descartado de proposito: acabaria com as pausas, mas qualquer ciclo criado em tempo de execucao vazaria pelo resto da sessao. Elevar o limiar mantem a rede de seguranca e adia a varredura para alem de uma partida inteira (design secao 36).

Efeito colateral que importa em teste: o congelamento e permanente e global. Quem chama isto fora do jogo (a suite, por criar Game centenas de vezes) precisa desfaze-lo com gc.unfreeze() e devolver os limiares.

Source code in src/perf.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def tune_gc() -> None:
    """Tira o coletor ciclico do caminho do frame (R27.3). Chamar ao fim da inicializacao.

    Sao tres passos, nessa ordem:

    1. `gc.collect()` — recolhe o lixo da propria inicializacao antes de congelar, para
       nao congelar justamente o que deveria ser jogado fora.
    2. `gc.freeze()` — move tudo que sobrou para a geracao permanente, que o coletor nao
       percorre. Quase todo o jogo e construido aqui e vive ate o fim (texturas, atlas,
       glifos, definicoes de bioma), entao isso remove de uma vez a maior parte do
       trabalho de qualquer varredura futura.
    3. `gc.set_threshold(*GC_THRESHOLD)` — o que ainda for varrido, sera muito mais raro.

    `gc.disable()` foi descartado de proposito: acabaria com as pausas, mas qualquer
    ciclo criado em tempo de execucao vazaria pelo resto da sessao. Elevar o limiar
    mantem a rede de seguranca e adia a varredura para alem de uma partida inteira
    (design secao 36).

    Efeito colateral que importa em teste: o congelamento e permanente e global. Quem
    chama isto fora do jogo (a suite, por criar `Game` centenas de vezes) precisa
    desfaze-lo com `gc.unfreeze()` e devolver os limiares.
    """
    gc.collect()
    gc.freeze()
    gc.set_threshold(*GC_THRESHOLD)

src.pipes

src.pipes

PipePair e PipeManager: obstaculos do jogo (R2).

PipeManager

Cria, move, descarta e desenha as colunas em cena para um bioma.

Source code in src/pipes.py
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
class PipeManager:
    """Cria, move, descarta e desenha as colunas em cena para um bioma."""

    def __init__(self, gap_size: int, block_main: str, block_edge: str) -> None:
        """Inicia sem colunas, com os parametros de abertura e textura do bioma atual."""
        self.pipes: list[PipePair] = []
        # nasce na borda direita da AREA JOGAVEL, nao do canvas: e o que faz o tempo
        # entre o surgimento da coluna e a chegada a abelha ser identico em qualquer
        # proporcao de tela (R24.3).
        self._spawn(config.play().right, gap_size, block_main, block_edge)

    def _spawn(self, x: float, gap_size: int, block_main: str, block_edge: str) -> None:
        # a abertura e sorteada dentro da AREA JOGAVEL: com uma faixa de ceu acima,
        # `GAP_MARGIN` sozinho deixaria o centro da abertura cair na decoracao, fora
        # do alcance da abelha (R24.1, R25.1).
        gap_y = random.uniform(config.play().top + GAP_MARGIN, config.ground_y() - GAP_MARGIN)
        self.pipes.append(PipePair(x, gap_y, gap_size, block_main, block_edge))

    def update(self, speed: float, gap_size: int, block_main: str, block_edge: str) -> None:
        """Move as colunas (R2.3), spawna novas com o bioma atual (R2.1/R2.2/R2.5).

        Remove as que saem da tela (R2.4).
        """
        for pipe in self.pipes:
            pipe.x -= speed
            pipe.sync_rects()

        if self.pipes[-1].x <= config.play().right - PIPE_SPACING:
            self._spawn(self.pipes[-1].x + PIPE_SPACING, gap_size, block_main, block_edge)

        # remocao no lugar: a compreensao de lista que estava aqui construia uma lista
        # nova a cada frame para descartar uma coluna a cada ~90 (R27.3).
        index = 0
        while index < len(self.pipes):
            if self.pipes[index].off_screen():
                del self.pipes[index]
            else:
                index += 1

    def sync_rects(self) -> None:
        """Reposiciona os retangulos de todas as colunas (R23.6).

        Chamado no redimensionamento, que muda a linha do chao sob colunas que nao se
        moveram.
        """
        for pipe in self.pipes:
            pipe.sync_rects()

    def draw(self, renderer: render.Renderer, textures: dict[str, pygame.Surface]) -> None:
        """Desenha so as colunas que caem dentro do canvas (R27.7)."""
        for pipe in self.pipes:
            if pipe.visible():
                pipe.draw(renderer, textures)
__init__(gap_size, block_main, block_edge)

Inicia sem colunas, com os parametros de abertura e textura do bioma atual.

Source code in src/pipes.py
135
136
137
138
139
140
141
def __init__(self, gap_size: int, block_main: str, block_edge: str) -> None:
    """Inicia sem colunas, com os parametros de abertura e textura do bioma atual."""
    self.pipes: list[PipePair] = []
    # nasce na borda direita da AREA JOGAVEL, nao do canvas: e o que faz o tempo
    # entre o surgimento da coluna e a chegada a abelha ser identico em qualquer
    # proporcao de tela (R24.3).
    self._spawn(config.play().right, gap_size, block_main, block_edge)
draw(renderer, textures)

Desenha so as colunas que caem dentro do canvas (R27.7).

Source code in src/pipes.py
180
181
182
183
184
def draw(self, renderer: render.Renderer, textures: dict[str, pygame.Surface]) -> None:
    """Desenha so as colunas que caem dentro do canvas (R27.7)."""
    for pipe in self.pipes:
        if pipe.visible():
            pipe.draw(renderer, textures)
sync_rects()

Reposiciona os retangulos de todas as colunas (R23.6).

Chamado no redimensionamento, que muda a linha do chao sob colunas que nao se moveram.

Source code in src/pipes.py
171
172
173
174
175
176
177
178
def sync_rects(self) -> None:
    """Reposiciona os retangulos de todas as colunas (R23.6).

    Chamado no redimensionamento, que muda a linha do chao sob colunas que nao se
    moveram.
    """
    for pipe in self.pipes:
        pipe.sync_rects()
update(speed, gap_size, block_main, block_edge)

Move as colunas (R2.3), spawna novas com o bioma atual (R2.1/R2.2/R2.5).

Remove as que saem da tela (R2.4).

Source code in src/pipes.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def update(self, speed: float, gap_size: int, block_main: str, block_edge: str) -> None:
    """Move as colunas (R2.3), spawna novas com o bioma atual (R2.1/R2.2/R2.5).

    Remove as que saem da tela (R2.4).
    """
    for pipe in self.pipes:
        pipe.x -= speed
        pipe.sync_rects()

    if self.pipes[-1].x <= config.play().right - PIPE_SPACING:
        self._spawn(self.pipes[-1].x + PIPE_SPACING, gap_size, block_main, block_edge)

    # remocao no lugar: a compreensao de lista que estava aqui construia uma lista
    # nova a cada frame para descartar uma coluna a cada ~90 (R27.3).
    index = 0
    while index < len(self.pipes):
        if self.pipes[index].off_screen():
            del self.pipes[index]
        else:
            index += 1

PipePair

Uma coluna com abertura: metade de cima e metade de baixo do obstaculo.

Sem __dict__ por instancia: ha varias colunas vivas a cada instante e todas tem exatamente estes campos (R27.3).

Source code in src/pipes.py
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
class PipePair:
    """Uma coluna com abertura: metade de cima e metade de baixo do obstaculo.

    Sem `__dict__` por instancia: ha varias colunas vivas a cada instante e todas
    tem exatamente estes campos (R27.3).
    """

    __slots__ = ("block_edge", "block_main", "bottom_rect", "gap_size", "gap_y", "scored", "top_rect", "x")

    def __init__(self, x: float, gap_y: float, gap_size: int, block_main: str, block_edge: str) -> None:
        """Cria a coluna em `x`, com a abertura centrada em `gap_y`."""
        self.x = x
        self.gap_y = gap_y
        self.gap_size = gap_size
        self.block_main = block_main
        self.block_edge = block_edge
        self.scored = False
        self.top_rect = pygame.Rect(0, 0, PIPE_W, 0)
        """Metade de cima da coluna, do topo do canvas ate a beirada da abertura."""
        self.bottom_rect = pygame.Rect(0, 0, PIPE_W, 0)
        """Metade de baixo, da outra beirada da abertura ate a linha do chao.

        Os dois sao atributos persistentes, e nao `property`: um frame de jogo os le
        quatro vezes por coluna — duas na colisao e duas no desenho — e cada leitura
        construia um `pygame.Rect` novo (R27.3). Quem move a coluna ou troca o canvas
        de baixo dela precisa chamar `sync_rects`."""
        self.sync_rects()

    def sync_rects(self) -> None:
        """Reposiciona as duas metades para o `x` atual e para a linha do chao atual.

        Depende de `config.ground_y()`, que muda quando a janela e redimensionada — por
        isso `Game.apply_resize` chama isto, e nao so o passo de simulacao: entre o
        redimensionamento e o proximo `update` ha frames desenhados (PRONTO, PAUSADO,
        GAME_OVER) que usariam a altura antiga.
        """
        x = round(self.x)
        self.top_rect.x = x
        self.top_rect.height = round(self.gap_y - self.gap_size / 2)
        top = round(self.gap_y + self.gap_size / 2)
        self.bottom_rect.x = x
        self.bottom_rect.y = top
        self.bottom_rect.height = config.ground_y() - top

    def off_screen(self) -> bool:
        """Descarte na borda esquerda da area jogavel, nao do canvas (R2.4).

        O que sai dela ja esta atras da faixa decorativa e nao volta a ser visto.
        """
        return self.x + PIPE_W < config.play().left

    def visible(self) -> bool:
        """Se alguma parte da coluna cai dentro do canvas (R27.7).

        A v2 desenhava sempre um par a mais do que precisava: `_spawn` cria a coluna
        em `play.right`, que num canvas 2:3 e a propria borda da tela. Eram duas
        pilhas inteiras de blocos por frame num obstaculo que ninguem podia ver.

        O recorte e contra o canvas, e nao contra a area jogavel: numa tela larga a
        coluna passa um bom tempo entre a borda do canvas e a da area jogavel, e la
        ela existe de verdade — e a faixa lateral, desenhada depois, que a esconde
        (R24.4). Descartar antes disso deixaria a faixa transparente onde ela nao
        cobrisse.
        """
        return -PIPE_W < round(self.x) < config.screen_w()

    def draw(self, renderer: render.Renderer, textures: dict[str, pygame.Surface]) -> None:
        """Duas faixas pre-renderizadas, dois desenhos por coluna (R27.2).

        A pilha de blocos e sempre a mesma imagem — o que muda de coluna para coluna
        e onde ela e cortada. Entao cada par de blocos vira duas faixas de altura de
        canvas, uma com a fileira de borda embaixo (coluna de cima) e outra com ela
        em cima (coluna de baixo), e desenhar uma coluna vira escolher o recorte.

        As texturas continuam sendo as do bioma congelado na criacao da coluna
        (R2.5): o par entra na chave das faixas, entao uma coluna do overworld que
        sobreviva a entrada na cave continua saindo de terra e grama.
        """
        height = config.screen_h()
        main = textures[self.block_main]
        edge = textures[self.block_edge]
        x = round(self.x)

        top = self.top_rect
        if top.height > 0:
            strip = renderer.image(
                ("pipe_top", self.block_main, self.block_edge, height),
                lambda: _make_strip(main, edge, height, edge_at_top=False),
            )
            # o corte sai da base da faixa: e la que esta a fileira de borda, que
            # precisa cair exatamente na beirada da abertura.
            renderer.draw(strip, (x, 0), pygame.Rect(0, height - top.height, PIPE_W, top.height))

        bottom = self.bottom_rect
        if bottom.height > 0:
            strip = renderer.image(
                ("pipe_bottom", self.block_main, self.block_edge, height),
                lambda: _make_strip(main, edge, height, edge_at_top=True),
            )
            renderer.draw(strip, (x, bottom.top), pygame.Rect(0, 0, PIPE_W, bottom.height))
bottom_rect = pygame.Rect(0, 0, PIPE_W, 0) instance-attribute

Metade de baixo, da outra beirada da abertura ate a linha do chao.

Os dois sao atributos persistentes, e nao property: um frame de jogo os le quatro vezes por coluna — duas na colisao e duas no desenho — e cada leitura construia um pygame.Rect novo (R27.3). Quem move a coluna ou troca o canvas de baixo dela precisa chamar sync_rects.

top_rect = pygame.Rect(0, 0, PIPE_W, 0) instance-attribute

Metade de cima da coluna, do topo do canvas ate a beirada da abertura.

__init__(x, gap_y, gap_size, block_main, block_edge)

Cria a coluna em x, com a abertura centrada em gap_y.

Source code in src/pipes.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def __init__(self, x: float, gap_y: float, gap_size: int, block_main: str, block_edge: str) -> None:
    """Cria a coluna em `x`, com a abertura centrada em `gap_y`."""
    self.x = x
    self.gap_y = gap_y
    self.gap_size = gap_size
    self.block_main = block_main
    self.block_edge = block_edge
    self.scored = False
    self.top_rect = pygame.Rect(0, 0, PIPE_W, 0)
    """Metade de cima da coluna, do topo do canvas ate a beirada da abertura."""
    self.bottom_rect = pygame.Rect(0, 0, PIPE_W, 0)
    """Metade de baixo, da outra beirada da abertura ate a linha do chao.

    Os dois sao atributos persistentes, e nao `property`: um frame de jogo os le
    quatro vezes por coluna — duas na colisao e duas no desenho — e cada leitura
    construia um `pygame.Rect` novo (R27.3). Quem move a coluna ou troca o canvas
    de baixo dela precisa chamar `sync_rects`."""
    self.sync_rects()
draw(renderer, textures)

Duas faixas pre-renderizadas, dois desenhos por coluna (R27.2).

A pilha de blocos e sempre a mesma imagem — o que muda de coluna para coluna e onde ela e cortada. Entao cada par de blocos vira duas faixas de altura de canvas, uma com a fileira de borda embaixo (coluna de cima) e outra com ela em cima (coluna de baixo), e desenhar uma coluna vira escolher o recorte.

As texturas continuam sendo as do bioma congelado na criacao da coluna (R2.5): o par entra na chave das faixas, entao uma coluna do overworld que sobreviva a entrada na cave continua saindo de terra e grama.

Source code in src/pipes.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def draw(self, renderer: render.Renderer, textures: dict[str, pygame.Surface]) -> None:
    """Duas faixas pre-renderizadas, dois desenhos por coluna (R27.2).

    A pilha de blocos e sempre a mesma imagem — o que muda de coluna para coluna
    e onde ela e cortada. Entao cada par de blocos vira duas faixas de altura de
    canvas, uma com a fileira de borda embaixo (coluna de cima) e outra com ela
    em cima (coluna de baixo), e desenhar uma coluna vira escolher o recorte.

    As texturas continuam sendo as do bioma congelado na criacao da coluna
    (R2.5): o par entra na chave das faixas, entao uma coluna do overworld que
    sobreviva a entrada na cave continua saindo de terra e grama.
    """
    height = config.screen_h()
    main = textures[self.block_main]
    edge = textures[self.block_edge]
    x = round(self.x)

    top = self.top_rect
    if top.height > 0:
        strip = renderer.image(
            ("pipe_top", self.block_main, self.block_edge, height),
            lambda: _make_strip(main, edge, height, edge_at_top=False),
        )
        # o corte sai da base da faixa: e la que esta a fileira de borda, que
        # precisa cair exatamente na beirada da abertura.
        renderer.draw(strip, (x, 0), pygame.Rect(0, height - top.height, PIPE_W, top.height))

    bottom = self.bottom_rect
    if bottom.height > 0:
        strip = renderer.image(
            ("pipe_bottom", self.block_main, self.block_edge, height),
            lambda: _make_strip(main, edge, height, edge_at_top=True),
        )
        renderer.draw(strip, (x, bottom.top), pygame.Rect(0, 0, PIPE_W, bottom.height))
off_screen()

Descarte na borda esquerda da area jogavel, nao do canvas (R2.4).

O que sai dela ja esta atras da faixa decorativa e nao volta a ser visto.

Source code in src/pipes.py
55
56
57
58
59
60
def off_screen(self) -> bool:
    """Descarte na borda esquerda da area jogavel, nao do canvas (R2.4).

    O que sai dela ja esta atras da faixa decorativa e nao volta a ser visto.
    """
    return self.x + PIPE_W < config.play().left
sync_rects()

Reposiciona as duas metades para o x atual e para a linha do chao atual.

Depende de config.ground_y(), que muda quando a janela e redimensionada — por isso Game.apply_resize chama isto, e nao so o passo de simulacao: entre o redimensionamento e o proximo update ha frames desenhados (PRONTO, PAUSADO, GAME_OVER) que usariam a altura antiga.

Source code in src/pipes.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def sync_rects(self) -> None:
    """Reposiciona as duas metades para o `x` atual e para a linha do chao atual.

    Depende de `config.ground_y()`, que muda quando a janela e redimensionada — por
    isso `Game.apply_resize` chama isto, e nao so o passo de simulacao: entre o
    redimensionamento e o proximo `update` ha frames desenhados (PRONTO, PAUSADO,
    GAME_OVER) que usariam a altura antiga.
    """
    x = round(self.x)
    self.top_rect.x = x
    self.top_rect.height = round(self.gap_y - self.gap_size / 2)
    top = round(self.gap_y + self.gap_size / 2)
    self.bottom_rect.x = x
    self.bottom_rect.y = top
    self.bottom_rect.height = config.ground_y() - top
visible()

Se alguma parte da coluna cai dentro do canvas (R27.7).

A v2 desenhava sempre um par a mais do que precisava: _spawn cria a coluna em play.right, que num canvas 2:3 e a propria borda da tela. Eram duas pilhas inteiras de blocos por frame num obstaculo que ninguem podia ver.

O recorte e contra o canvas, e nao contra a area jogavel: numa tela larga a coluna passa um bom tempo entre a borda do canvas e a da area jogavel, e la ela existe de verdade — e a faixa lateral, desenhada depois, que a esconde (R24.4). Descartar antes disso deixaria a faixa transparente onde ela nao cobrisse.

Source code in src/pipes.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def visible(self) -> bool:
    """Se alguma parte da coluna cai dentro do canvas (R27.7).

    A v2 desenhava sempre um par a mais do que precisava: `_spawn` cria a coluna
    em `play.right`, que num canvas 2:3 e a propria borda da tela. Eram duas
    pilhas inteiras de blocos por frame num obstaculo que ninguem podia ver.

    O recorte e contra o canvas, e nao contra a area jogavel: numa tela larga a
    coluna passa um bom tempo entre a borda do canvas e a da area jogavel, e la
    ela existe de verdade — e a faixa lateral, desenhada depois, que a esconde
    (R24.4). Descartar antes disso deixaria a faixa transparente onde ela nao
    cobrisse.
    """
    return -PIPE_W < round(self.x) < config.screen_w()

src.pixelfont

src.pixelfont

Fonte bitmap 5x7 gerada por codigo, sem depender de fontes do sistema (R7.6).

Necessaria porque o Android nao possui as fontes usadas via pygame.font.SysFont na v1 (ex.: "couriernew"), o que quebraria o alinhamento/legibilidade da UI.

render(text, scale, color)

Renderiza texto (maiusculas) na fonte bitmap 5x7, com cache por (texto, escala, cor).

A conversao para o formato do display (R27.4) acontece ao preencher o cache, e nao a cada chamada: a superficie e montada uma vez por (texto, escala, cor) e devolvida pronta dai em diante. E onde a placar, que muda de texto a cada ponto, paga a conversao — uma vez por valor novo, nunca por frame.

Source code in src/pixelfont.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def render(text: str, scale: int, color: tuple[int, int, int]) -> pygame.Surface:
    """Renderiza texto (maiusculas) na fonte bitmap 5x7, com cache por (texto, escala, cor).

    A conversao para o formato do display (R27.4) acontece ao preencher o cache, e
    nao a cada chamada: a superficie e montada uma vez por (texto, escala, cor) e
    devolvida pronta dai em diante. E onde a placar, que muda de texto a cada ponto,
    paga a conversao — uma vez por valor novo, nunca por frame.
    """
    text = text.upper()
    key = (text, scale, color)
    cached = _cache.get(key)
    if cached is None:
        cached = convert(_render_uncached(text, scale, color))
        _cache[key] = cached
    return cached

src.quality

src.quality

Qualidade adaptativa: o jogo mede o proprio desempenho e se ajusta (R29).

Nem todo aparelho vai sustentar 60 FPS depois de tudo que a v3 fez. A alternativa a este modulo nao e "rodar liso em todo lugar" — e engasgar num aparelho modesto e deixar o jogador com a impressao de que o jogo e ruim. Aqui o jogo desliga decoracao ate caber.

A regra que nao se negocia (R29.3). Nenhum nivel toca em velocidade de coluna, tamanho de abertura, hitbox ou pontuacao. O que se desliga e sempre decorativo, em ordem crescente de importancia visual: primeiro os mobs e a camada distante do parallax, depois o parallax inteiro. Isso e o que preserva a comparabilidade dos recordes que R24 garante — dois jogadores em aparelhos diferentes continuam jogando o mesmo jogo, um deles com menos enfeite.

Nivel BAIXO reduz o desenho, nao a simulacao. render_fps cai para 30, e os passos logicos continuam sendo 60 por segundo (o acumulador da secao 37 cuida disso). Menos quadros, mesma velocidade de jogo — que e precisamente a distincao que R28 existe para fazer.

Ver specs/v3/design.md secao 38.

DROP_FPS = 50.0 module-attribute

Media abaixo disto na janela inteira derruba um nivel (R29.2).

FILENAME = 'quality.json' module-attribute

Ao lado do highscore.json, no diretorio gravavel por plataforma (R29.5).

RISE_FPS = 58.0 module-attribute

Media acima disto sobe um nivel. A folga de 8 FPS para o limiar de queda e a histerese propriamente dita (R29.4): entre 50 e 58 nada acontece, e e essa faixa morta que impede um aparelho no limite de alternar para sempre.

RISE_WINDOW_FRAMES = 300 module-attribute

Janela para subir: ~5 s, mais que o dobro da de descida (R29.4).

Assimetrica de proposito. Descer e barato e reversivel; subir cedo demais devolve o engasgo que motivou a descida, e o jogador ve o cenario piscando entre dois niveis.

WINDOW_FRAMES = 120 module-attribute

Janela de medicao para descer de nivel: ~2 s a 60 FPS (R29.1).

Curta o bastante para que os primeiros segundos ruins nao se arrastem, longa o bastante para que um engasgo isolado nao decida nada — um travamento de 300 ms no meio de 120 quadros derruba a media de 60 para ~52 FPS, bem acima do limiar de queda.

Level

Bases: IntEnum

Os tres niveis, ordenados: BAIXO < MEDIO < ALTO.

IntEnum porque a ordem e usada — subir e descer de nivel sao comparacoes, e "esta abaixo do maximo" e um <.

Source code in src/quality.py
34
35
36
37
38
39
40
41
42
43
class Level(IntEnum):
    """Os tres niveis, ordenados: `BAIXO < MEDIO < ALTO`.

    `IntEnum` porque a ordem e usada — subir e descer de nivel sao comparacoes, e
    "esta abaixo do maximo" e um `<`.
    """

    BAIXO = 0
    MEDIO = 1
    ALTO = 2

Quality

Mede a taxa de quadros em JOGANDO e ajusta o nivel (R29.1, R29.2, R29.4).

O estado sao tres numeros: quantos quadros ja entraram na janela, quanto tempo eles somaram, e o nivel atual. Sem lista, sem alocacao por quadro — esta medicao roda no caminho quente do laco e nao pode ser ela mesma o custo (secao 36 do design).

Source code in src/quality.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
class Quality:
    """Mede a taxa de quadros em JOGANDO e ajusta o nivel (R29.1, R29.2, R29.4).

    O estado sao tres numeros: quantos quadros ja entraram na janela, quanto tempo eles
    somaram, e o nivel atual. Sem lista, sem alocacao por quadro — esta medicao roda no
    caminho quente do laco e nao pode ser ela mesma o custo (secao 36 do design).
    """

    __slots__ = ("_count", "_total_ms", "dirty", "level")

    def __init__(self, level: Level = Level.ALTO) -> None:
        """Comeca no nivel dado — o gravado da sessao anterior, no jogo (R29.5)."""
        self.level = level
        self.dirty = False
        """Nivel mudou e ainda nao foi gravado. Ver `Game._flush_quality`: gravar aqui
        seria escrita em disco dentro do frame de JOGANDO, que R27.5 proibe."""
        self._count = 0
        self._total_ms = 0.0

    @property
    def settings(self) -> Settings:
        """O que esta ligado no nivel atual."""
        return SETTINGS[self.level]

    @property
    def render_fps(self) -> int:
        """Limite de quadros por segundo para o relogio do laco principal."""
        return self.settings.render_fps

    def frame(self, frame_ms: float, playing: bool) -> None:
        """Registra um quadro e, ao fechar a janela, decide se troca de nivel.

        `playing` e falso fora de JOGANDO, e ai a janela e **descartada**, nao apenas
        pausada (R29.1): a tela PRONTO desenha menos e o GAME_OVER congela o cenario,
        entao as duas medem outra coisa. Pior, uma janela que atravessasse a pausa
        mediria o tempo parado como quadros lentissimos e derrubaria o nivel de um
        aparelho que estava indo bem.
        """
        if not playing:
            self._reset_window()
            return

        self._count += 1
        self._total_ms += frame_ms
        if self._count < WINDOW_FRAMES:
            return

        # a janela curta so pode descer; a longa, que a contem, tambem pode subir. E a
        # assimetria de R29.4 escrita como duas janelas em vez de dois limiares.
        fps = self._fps()
        long_window = self._count >= RISE_WINDOW_FRAMES
        if fps < DROP_FPS and self.level > Level.BAIXO:
            self._change(Level(self.level - 1))
        elif long_window and fps > RISE_FPS and self.level < Level.ALTO:
            self._change(Level(self.level + 1))
        elif not long_window:
            return  # janela curta sem queda: segue medindo, sem reiniciar a contagem
        self._reset_window()

    def _change(self, level: Level) -> None:
        """Troca o nivel e marca para gravacao."""
        self.level = level
        self.dirty = True

    def _fps(self) -> float:
        """Taxa media da janela, ou 0 se nada foi medido."""
        return 1000.0 * self._count / self._total_ms if self._total_ms > 0 else 0.0

    def _reset_window(self) -> None:
        """Recomeca a medicao do zero."""
        self._count = 0
        self._total_ms = 0.0
dirty = False instance-attribute

Nivel mudou e ainda nao foi gravado. Ver Game._flush_quality: gravar aqui seria escrita em disco dentro do frame de JOGANDO, que R27.5 proibe.

render_fps property

Limite de quadros por segundo para o relogio do laco principal.

settings property

O que esta ligado no nivel atual.

__init__(level=Level.ALTO)

Comeca no nivel dado — o gravado da sessao anterior, no jogo (R29.5).

Source code in src/quality.py
141
142
143
144
145
146
147
148
def __init__(self, level: Level = Level.ALTO) -> None:
    """Comeca no nivel dado — o gravado da sessao anterior, no jogo (R29.5)."""
    self.level = level
    self.dirty = False
    """Nivel mudou e ainda nao foi gravado. Ver `Game._flush_quality`: gravar aqui
    seria escrita em disco dentro do frame de JOGANDO, que R27.5 proibe."""
    self._count = 0
    self._total_ms = 0.0
frame(frame_ms, playing)

Registra um quadro e, ao fechar a janela, decide se troca de nivel.

playing e falso fora de JOGANDO, e ai a janela e descartada, nao apenas pausada (R29.1): a tela PRONTO desenha menos e o GAME_OVER congela o cenario, entao as duas medem outra coisa. Pior, uma janela que atravessasse a pausa mediria o tempo parado como quadros lentissimos e derrubaria o nivel de um aparelho que estava indo bem.

Source code in src/quality.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def frame(self, frame_ms: float, playing: bool) -> None:
    """Registra um quadro e, ao fechar a janela, decide se troca de nivel.

    `playing` e falso fora de JOGANDO, e ai a janela e **descartada**, nao apenas
    pausada (R29.1): a tela PRONTO desenha menos e o GAME_OVER congela o cenario,
    entao as duas medem outra coisa. Pior, uma janela que atravessasse a pausa
    mediria o tempo parado como quadros lentissimos e derrubaria o nivel de um
    aparelho que estava indo bem.
    """
    if not playing:
        self._reset_window()
        return

    self._count += 1
    self._total_ms += frame_ms
    if self._count < WINDOW_FRAMES:
        return

    # a janela curta so pode descer; a longa, que a contem, tambem pode subir. E a
    # assimetria de R29.4 escrita como duas janelas em vez de dois limiares.
    fps = self._fps()
    long_window = self._count >= RISE_WINDOW_FRAMES
    if fps < DROP_FPS and self.level > Level.BAIXO:
        self._change(Level(self.level - 1))
    elif long_window and fps > RISE_FPS and self.level < Level.ALTO:
        self._change(Level(self.level + 1))
    elif not long_window:
        return  # janela curta sem queda: segue medindo, sem reiniciar a contagem
    self._reset_window()

Settings dataclass

O que cada nivel liga e desliga. Tudo aqui e decorativo, por construcao (R29.3).

Source code in src/quality.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@dataclass(frozen=True)
class Settings:
    """O que cada nivel liga e desliga. Tudo aqui e decorativo, por construcao (R29.3)."""

    mobs: bool
    """Mobs decorativos das faixas (`mobs.py`, R25.3)."""

    far_parallax: bool
    """Camada distante do parallax — nuvens, estalactites, poças de lava."""

    near_parallax: bool
    """Camada proxima — colinas, veios de minerio, pilares."""

    particles: int
    """Particulas por explosao de colisao (`particles.burst`, R3.2)."""

    render_fps: int
    """Quadros desenhados por segundo. A simulacao continua a 60 passos logicos."""
far_parallax instance-attribute

Camada distante do parallax — nuvens, estalactites, poças de lava.

mobs instance-attribute

Mobs decorativos das faixas (mobs.py, R25.3).

near_parallax instance-attribute

Camada proxima — colinas, veios de minerio, pilares.

particles instance-attribute

Particulas por explosao de colisao (particles.burst, R3.2).

render_fps instance-attribute

Quadros desenhados por segundo. A simulacao continua a 60 passos logicos.

load_level(path=None)

Le o nivel gravado, ou ALTO se nao houver um utilizavel (R29.5, R29.6).

Mesma disciplina de score.load_highscore: arquivo ausente, ilegivel, com JSON quebrado ou com um nivel que nao existe resulta em ALTO e redeteccao. Um arquivo de preferencia corrompido nunca pode impedir o jogo de abrir — e comecar no maximo e a escolha certa por ser a unica que se corrige sozinha em dois segundos, ao contrario de comecar no minimo.

Source code in src/quality.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def load_level(path: Path | None = None) -> Level:
    """Le o nivel gravado, ou ALTO se nao houver um utilizavel (R29.5, R29.6).

    Mesma disciplina de `score.load_highscore`: arquivo ausente, ilegivel, com JSON
    quebrado ou com um nivel que nao existe resulta em ALTO e redeteccao. Um arquivo
    de preferencia corrompido nunca pode impedir o jogo de abrir — e comecar no maximo
    e a escolha certa por ser a unica que se corrige sozinha em dois segundos, ao
    contrario de comecar no minimo.
    """
    path = path if path is not None else _default_path()
    try:
        with open(path, encoding="utf-8") as file:
            data = json.load(file)
        return Level[str(data["level"])]
    except (OSError, ValueError, KeyError, TypeError):
        return Level.ALTO

save_level(level, path=None)

Grava o nivel detectado (R29.5). Falha de escrita e ignorada, como no recorde.

Source code in src/quality.py
112
113
114
115
116
117
118
119
def save_level(level: Level, path: Path | None = None) -> None:
    """Grava o nivel detectado (R29.5). Falha de escrita e ignorada, como no recorde."""
    path = path if path is not None else _default_path()
    try:
        with open(path, "w", encoding="utf-8") as file:
            json.dump({"level": level.name}, file)
    except OSError:
        pass

src.render

src.render

Camada de renderizacao: uma interface, dois caminhos de desenho (R26).

A v2 passava uma pygame.Surface de modulo em modulo e desenhava nela. A v3 quer a GPU do aparelho, mas nao pode exigi-la: o projeto vai ate a API 21 do Android, onde um renderizador acelerado nao e garantido. Este modulo resolve os dois lados de uma vez — expoe uma interface pequena (sete operacoes, secao 33.1 do design) que bird, pipes, ground, decor, particles, biome e ui usam sem saber qual caminho esta ativo (R26.4), e escolhe o caminho sozinho numa cascata de tres niveis (R26.1, R26.2, R26.3):

Nivel Tentativa backend
1 Renderer(window, accelerated=1, vsync=True) gpu-accelerated
2 Renderer(window, accelerated=-1, vsync=True) gpu-software
3 SurfaceRenderer — Surface.blit, o caminho da v2 surface

Cada queda de nivel e registrada em log com o erro que a causou (R26.5) e nenhuma falha escapa de create(): o jogo nunca deixa de abrir por causa do renderizador, mesma disciplina de degradacao graciosa que o audio segue desde a v1 (R8.4).

Ver specs/v3/design.md secao 33.

Dest = pygame.Rect | tuple[int, int] module-attribute

Destino de um desenho: um Rect (o desenho e esticado para ele) ou apenas o canto superior esquerdo (o desenho sai no tamanho nativo da imagem).

ENV_RENDER_BATCHING = 'SDL_RENDER_BATCHING' module-attribute

Nome de ambiente do hint SDL_HINT_RENDER_BATCHING.

1 agrupa desenhos consecutivos que compartilham textura e modo de mistura numa so submissao ao driver. E o que transforma os muitos blit de um frame — o chao ladrilhado, as colunas, os glifos do HUD — em poucas chamadas de verdade (design secao 40). Lido pelo SDL na criacao do renderizador, entao tem que estar no ambiente antes dela.

ENV_SCALE_QUALITY = 'SDL_RENDER_SCALE_QUALITY' module-attribute

Nome de ambiente do hint SDL_HINT_RENDER_SCALE_QUALITY.

0 e vizinho mais proximo: simultaneamente o mais barato e o unico correto para arte em pixel, porque filtragem linear borraria a estetica voxel do jogo (R26.7).

GpuImage

Bases: Image

Imagem residente na GPU: uma Texture do pygame._sdl2.video.

Source code in src/render.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
class GpuImage(Image):
    """Imagem residente na GPU: uma `Texture` do `pygame._sdl2.video`."""

    __slots__ = ("raw", "size")

    raw: "video.Texture"

    def __init__(self, texture: "video.Texture") -> None:
        """Envolve uma textura ja enviada para a GPU."""
        self.raw = texture
        self.size = (texture.width, texture.height)

    @property
    def alpha(self) -> int:
        """Opacidade aplicada pela GPU na hora do desenho, sem tocar nos pixels."""
        return self.raw.alpha

    @alpha.setter
    def alpha(self, value: int) -> None:
        self.raw.alpha = value
alpha property writable

Opacidade aplicada pela GPU na hora do desenho, sem tocar nos pixels.

__init__(texture)

Envolve uma textura ja enviada para a GPU.

Source code in src/render.py
203
204
205
206
def __init__(self, texture: "video.Texture") -> None:
    """Envolve uma textura ja enviada para a GPU."""
    self.raw = texture
    self.size = (texture.width, texture.height)

GpuRenderer

Bases: Renderer

Desenho pela GPU, sobre pygame._sdl2.video (design secao 33.2).

logical_size faz o proprio SDL escalar o canvas para a janela, na GPU — o mesmo que pygame.SCALED fazia na v2, mas agora com todo o desenho anterior tambem na GPU. vsync=True elimina quadros desperdicados e rasgo de imagem (R26.6).

Source code in src/render.py
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
class GpuRenderer(Renderer):
    """Desenho pela GPU, sobre `pygame._sdl2.video` (design secao 33.2).

    `logical_size` faz o proprio SDL escalar o canvas para a janela, na GPU — o mesmo
    que `pygame.SCALED` fazia na v2, mas agora com todo o desenho anterior tambem na
    GPU. `vsync=True` elimina quadros desperdicados e rasgo de imagem (R26.6).
    """

    def __init__(
        self,
        window: "video.Window",
        renderer: "video.Renderer",
        canvas: tuple[int, int],
        backend: str,
    ) -> None:
        """Assume a posse de uma janela e de um renderizador SDL ja criados."""
        super().__init__()
        self.window = window
        self.sdl = renderer
        self.sdl.logical_size = canvas
        self.size = canvas
        self.backend = backend

    @property
    def window_size(self) -> tuple[int, int]:
        """Tamanho da janela do `_sdl2`, que o SDL mantem atualizado sozinho."""
        return self.window.size

    def resize(self, canvas: tuple[int, int]) -> None:
        """Troca o canvas logico. Uma atribuicao, e o SDL reescala na GPU."""
        self.sdl.logical_size = canvas
        self.size = canvas

    def make_image(self, surface: pygame.Surface) -> Image:
        """Envia a superficie para a GPU uma unica vez (R27.2).

        O `blend_mode` e fixado em mistura alfa para casar com o caminho de
        superficie, onde uma superficie com alfa por pixel mistura ao ser blitada e
        `Image.alpha` vale para as duas. Numa textura opaca com alfa 255 a mistura
        nao muda o resultado, entao um so modo serve para os dois casos.

        Aqui nao se converte: `Texture.from_surface` ja faz a conversao no envio, e
        no caminho de GPU nao ha formato de display para converter (a janela vem do
        `_sdl2`, sem `display.set_mode`).
        """
        texture = video.Texture.from_surface(self.sdl, surface)
        texture.blend_mode = pygame.BLENDMODE_BLEND
        return GpuImage(texture)

    def clear(self, color: Color) -> None:
        """Preenche o canvas inteiro com `color`."""
        self.sdl.draw_color = color
        self.sdl.clear()

    def draw(self, image: Image, dest: Dest, area: pygame.Rect | None = None) -> None:
        """Desenha a textura, esticando-a quando `dest` traz um tamanho."""
        self.sdl.blit(image.raw, _dest_rect(dest, image, area), area)

    def fill(self, color: Color, rect: pygame.Rect) -> None:
        """Preenche `rect`, ligando a mistura alfa so quando a cor pede."""
        self.sdl.draw_blend_mode = pygame.BLENDMODE_BLEND if _has_alpha(color) else pygame.BLENDMODE_NONE
        self.sdl.draw_color = color
        self.sdl.fill_rect(rect)

    def present(self) -> None:
        """Troca o buffer da GPU."""
        self.sdl.present()

    def to_logical(self, x: float, y: float) -> tuple[float, float]:
        """Desfaz a escala de `logical_size`, pelo proprio SDL."""
        return self.sdl.coordinates_from_window((x, y))

    def snapshot(self) -> pygame.Surface:
        """Le o alvo de renderizacao de volta para uma `Surface`."""
        return self.sdl.to_surface()
window_size property

Tamanho da janela do _sdl2, que o SDL mantem atualizado sozinho.

__init__(window, renderer, canvas, backend)

Assume a posse de uma janela e de um renderizador SDL ja criados.

Source code in src/render.py
253
254
255
256
257
258
259
260
261
262
263
264
265
266
def __init__(
    self,
    window: "video.Window",
    renderer: "video.Renderer",
    canvas: tuple[int, int],
    backend: str,
) -> None:
    """Assume a posse de uma janela e de um renderizador SDL ja criados."""
    super().__init__()
    self.window = window
    self.sdl = renderer
    self.sdl.logical_size = canvas
    self.size = canvas
    self.backend = backend
clear(color)

Preenche o canvas inteiro com color.

Source code in src/render.py
294
295
296
297
def clear(self, color: Color) -> None:
    """Preenche o canvas inteiro com `color`."""
    self.sdl.draw_color = color
    self.sdl.clear()
draw(image, dest, area=None)

Desenha a textura, esticando-a quando dest traz um tamanho.

Source code in src/render.py
299
300
301
def draw(self, image: Image, dest: Dest, area: pygame.Rect | None = None) -> None:
    """Desenha a textura, esticando-a quando `dest` traz um tamanho."""
    self.sdl.blit(image.raw, _dest_rect(dest, image, area), area)
fill(color, rect)

Preenche rect, ligando a mistura alfa so quando a cor pede.

Source code in src/render.py
303
304
305
306
307
def fill(self, color: Color, rect: pygame.Rect) -> None:
    """Preenche `rect`, ligando a mistura alfa so quando a cor pede."""
    self.sdl.draw_blend_mode = pygame.BLENDMODE_BLEND if _has_alpha(color) else pygame.BLENDMODE_NONE
    self.sdl.draw_color = color
    self.sdl.fill_rect(rect)
make_image(surface)

Envia a superficie para a GPU uma unica vez (R27.2).

O blend_mode e fixado em mistura alfa para casar com o caminho de superficie, onde uma superficie com alfa por pixel mistura ao ser blitada e Image.alpha vale para as duas. Numa textura opaca com alfa 255 a mistura nao muda o resultado, entao um so modo serve para os dois casos.

Aqui nao se converte: Texture.from_surface ja faz a conversao no envio, e no caminho de GPU nao ha formato de display para converter (a janela vem do _sdl2, sem display.set_mode).

Source code in src/render.py
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
def make_image(self, surface: pygame.Surface) -> Image:
    """Envia a superficie para a GPU uma unica vez (R27.2).

    O `blend_mode` e fixado em mistura alfa para casar com o caminho de
    superficie, onde uma superficie com alfa por pixel mistura ao ser blitada e
    `Image.alpha` vale para as duas. Numa textura opaca com alfa 255 a mistura
    nao muda o resultado, entao um so modo serve para os dois casos.

    Aqui nao se converte: `Texture.from_surface` ja faz a conversao no envio, e
    no caminho de GPU nao ha formato de display para converter (a janela vem do
    `_sdl2`, sem `display.set_mode`).
    """
    texture = video.Texture.from_surface(self.sdl, surface)
    texture.blend_mode = pygame.BLENDMODE_BLEND
    return GpuImage(texture)
present()

Troca o buffer da GPU.

Source code in src/render.py
309
310
311
def present(self) -> None:
    """Troca o buffer da GPU."""
    self.sdl.present()
resize(canvas)

Troca o canvas logico. Uma atribuicao, e o SDL reescala na GPU.

Source code in src/render.py
273
274
275
276
def resize(self, canvas: tuple[int, int]) -> None:
    """Troca o canvas logico. Uma atribuicao, e o SDL reescala na GPU."""
    self.sdl.logical_size = canvas
    self.size = canvas
snapshot()

Le o alvo de renderizacao de volta para uma Surface.

Source code in src/render.py
317
318
319
def snapshot(self) -> pygame.Surface:
    """Le o alvo de renderizacao de volta para uma `Surface`."""
    return self.sdl.to_surface()
to_logical(x, y)

Desfaz a escala de logical_size, pelo proprio SDL.

Source code in src/render.py
313
314
315
def to_logical(self, x: float, y: float) -> tuple[float, float]:
    """Desfaz a escala de `logical_size`, pelo proprio SDL."""
    return self.sdl.coordinates_from_window((x, y))

Image

Uma imagem pronta para desenhar, no formato que o renderizador ativo entende.

Nenhum modulo de jogo constroi uma diretamente: todas nascem de Renderer.make_image, a partir de uma Surface gerada por codigo na inicializacao (R7.1). alpha e de leitura e escrita, e e o que o fade de bioma (R5.4) e o escurecimento das telas de pausa e game over usam.

Source code in src/render.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
class Image:
    """Uma imagem pronta para desenhar, no formato que o renderizador ativo entende.

    Nenhum modulo de jogo constroi uma diretamente: todas nascem de
    `Renderer.make_image`, a partir de uma `Surface` gerada por codigo na
    inicializacao (R7.1). `alpha` e de leitura e escrita, e e o que o fade de bioma
    (R5.4) e o escurecimento das telas de pausa e game over usam.
    """

    __slots__ = ()

    size: tuple[int, int]
    """Largura e altura em pixels de canvas."""

    raw: Any
    """A imagem de verdade — `Texture` na GPU, `Surface` fora dela. So o renderizador
    que a criou desempacota este campo; para os modulos de jogo a imagem e opaca."""

    @property
    def alpha(self) -> int:
        """Opacidade da imagem inteira, de 0 (invisivel) a 255 (opaca)."""
        raise NotImplementedError

    @alpha.setter
    def alpha(self, value: int) -> None:
        raise NotImplementedError
alpha property writable

Opacidade da imagem inteira, de 0 (invisivel) a 255 (opaca).

raw instance-attribute

A imagem de verdade — Texture na GPU, Surface fora dela. So o renderizador que a criou desempacota este campo; para os modulos de jogo a imagem e opaca.

size instance-attribute

Largura e altura em pixels de canvas.

Renderer

Interface de desenho comum aos dois caminhos (R26.4).

As sete operacoes da seção 33.1 cobrem o desenho inteiro. A que carrega mais peso e draw com area opcional: e ela que permite recortar uma faixa de uma strip pre-renderizada, que e o mecanismo do atlas (design secao 34) — no caminho de GPU vira o srcrect do Renderer.blit, e no de superficie o terceiro argumento de Surface.blit.

Ao redor delas ficam as operacoes que existem porque, desde a task 49, e o renderizador que possui a janela: image/forget_images (o cache de imagens construidas por codigo), window_size, resize e snapshot.

Source code in src/render.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
class Renderer:
    """Interface de desenho comum aos dois caminhos (R26.4).

    As sete operacoes da seção 33.1 cobrem o desenho inteiro. A que carrega mais peso
    e `draw` com `area` opcional: e ela que permite recortar uma faixa de uma strip
    pre-renderizada, que e o mecanismo do atlas (design secao 34) — no caminho de GPU
    vira o `srcrect` do `Renderer.blit`, e no de superficie o terceiro argumento de
    `Surface.blit`.

    Ao redor delas ficam as operacoes que existem porque, desde a task 49, e o
    renderizador que possui a janela: `image`/`forget_images` (o cache de imagens
    construidas por codigo), `window_size`, `resize` e `snapshot`.
    """

    size: tuple[int, int]
    """Tamanho do canvas logico. Todo desenho e feito nessas coordenadas; levar o
    canvas para a tela real e problema do renderizador, nao de quem desenha."""

    backend: str
    """Caminho efetivamente em uso, exposto ao log e a instrumentacao (R26.5)."""

    def __init__(self) -> None:
        """Prepara o cache de imagens comum aos dois caminhos."""
        self._images: dict[object, Image] = {}

    def make_image(self, surface: pygame.Surface) -> Image:
        """Converte uma `Surface` numa imagem do renderizador ativo."""
        raise NotImplementedError

    def image(self, key: object, factory: Callable[[], pygame.Surface]) -> Image:
        """Imagem construida uma vez e guardada com o renderizador (R27.2).

        `factory` so e chamada no primeiro pedido de cada `key`. E por aqui que todo
        conteudo gerado por codigo — texturas de bloco, glifos de texto, gradientes de
        ceu, sprites rotacionados da abelha — vira imagem sem que nenhum modulo de
        desenho precise guardar cache proprio nem saber qual backend esta ativo.
        """
        image = self._images.get(key)
        if image is None:
            image = self.make_image(factory())
            self._images[key] = image
        return image

    def forget_images(self) -> None:
        """Descarta o cache de imagens.

        Usado no redimensionamento: gradiente de ceu e faixas laterais sao chaveados
        pelo tamanho do canvas, entao as versoes antigas nunca mais seriam pedidas e
        ficariam ocupando memoria de video a toa.
        """
        self._images.clear()

    @property
    def window_size(self) -> tuple[int, int]:
        """Tamanho real da janela em pixels, que nao e o do canvas logico."""
        raise NotImplementedError

    def resize(self, canvas: tuple[int, int]) -> None:
        """Troca o canvas logico, acompanhando a janela que o jogador arrastou (R23.6)."""
        raise NotImplementedError

    def clear(self, color: Color) -> None:
        """Preenche o canvas inteiro, apagando o frame anterior."""
        raise NotImplementedError

    def draw(self, image: Image, dest: Dest, area: pygame.Rect | None = None) -> None:
        """Desenha `image` (ou o recorte `area` dela) em `dest`."""
        raise NotImplementedError

    def fill(self, color: Color, rect: pygame.Rect) -> None:
        """Preenche um retangulo com uma cor, misturando quando ela tem alfa."""
        raise NotImplementedError

    def present(self) -> None:
        """Publica o frame montado, levando o canvas logico para a tela real."""
        raise NotImplementedError

    def to_logical(self, x: float, y: float) -> tuple[float, float]:
        """Converte um ponto em pixels reais da janela para coordenada de canvas.

        Os dois caminhos convertem a mesma coisa da mesma forma, e e isso que permite
        a `input.py` tratar mouse e toque por um caminho unico (R34.1).
        """
        raise NotImplementedError

    def snapshot(self) -> pygame.Surface:
        """Le o canvas de volta como `Surface`. Diagnostico e testes apenas.

        No caminho de GPU e uma transferencia de VRAM para RAM, cara demais para o
        laco principal. Chamar antes de `present()`.

        Limite do caminho de GPU, medido: `Renderer.to_surface` devolve uma superficie
        do tamanho logico mas copia os pixels *fisicos* do alvo, sem desfazer a escala
        de `logical_size`. So e fiel quando o canvas tem o tamanho da janela — que e o
        caso dos testes e do desktop. Com escala diferente de 1 a leitura sai recortada;
        para conferir um canvas de celular no PC, force a janela com `BLOCKY_CANVAS` ou
        use o `SurfaceRenderer`, cujo snapshot e sempre o canvas inteiro.
        """
        raise NotImplementedError
backend instance-attribute

Caminho efetivamente em uso, exposto ao log e a instrumentacao (R26.5).

size instance-attribute

Tamanho do canvas logico. Todo desenho e feito nessas coordenadas; levar o canvas para a tela real e problema do renderizador, nao de quem desenha.

window_size property

Tamanho real da janela em pixels, que nao e o do canvas logico.

__init__()

Prepara o cache de imagens comum aos dois caminhos.

Source code in src/render.py
116
117
118
def __init__(self) -> None:
    """Prepara o cache de imagens comum aos dois caminhos."""
    self._images: dict[object, Image] = {}
clear(color)

Preenche o canvas inteiro, apagando o frame anterior.

Source code in src/render.py
156
157
158
def clear(self, color: Color) -> None:
    """Preenche o canvas inteiro, apagando o frame anterior."""
    raise NotImplementedError
draw(image, dest, area=None)

Desenha image (ou o recorte area dela) em dest.

Source code in src/render.py
160
161
162
def draw(self, image: Image, dest: Dest, area: pygame.Rect | None = None) -> None:
    """Desenha `image` (ou o recorte `area` dela) em `dest`."""
    raise NotImplementedError
fill(color, rect)

Preenche um retangulo com uma cor, misturando quando ela tem alfa.

Source code in src/render.py
164
165
166
def fill(self, color: Color, rect: pygame.Rect) -> None:
    """Preenche um retangulo com uma cor, misturando quando ela tem alfa."""
    raise NotImplementedError
forget_images()

Descarta o cache de imagens.

Usado no redimensionamento: gradiente de ceu e faixas laterais sao chaveados pelo tamanho do canvas, entao as versoes antigas nunca mais seriam pedidas e ficariam ocupando memoria de video a toa.

Source code in src/render.py
138
139
140
141
142
143
144
145
def forget_images(self) -> None:
    """Descarta o cache de imagens.

    Usado no redimensionamento: gradiente de ceu e faixas laterais sao chaveados
    pelo tamanho do canvas, entao as versoes antigas nunca mais seriam pedidas e
    ficariam ocupando memoria de video a toa.
    """
    self._images.clear()
image(key, factory)

Imagem construida uma vez e guardada com o renderizador (R27.2).

factory so e chamada no primeiro pedido de cada key. E por aqui que todo conteudo gerado por codigo — texturas de bloco, glifos de texto, gradientes de ceu, sprites rotacionados da abelha — vira imagem sem que nenhum modulo de desenho precise guardar cache proprio nem saber qual backend esta ativo.

Source code in src/render.py
124
125
126
127
128
129
130
131
132
133
134
135
136
def image(self, key: object, factory: Callable[[], pygame.Surface]) -> Image:
    """Imagem construida uma vez e guardada com o renderizador (R27.2).

    `factory` so e chamada no primeiro pedido de cada `key`. E por aqui que todo
    conteudo gerado por codigo — texturas de bloco, glifos de texto, gradientes de
    ceu, sprites rotacionados da abelha — vira imagem sem que nenhum modulo de
    desenho precise guardar cache proprio nem saber qual backend esta ativo.
    """
    image = self._images.get(key)
    if image is None:
        image = self.make_image(factory())
        self._images[key] = image
    return image
make_image(surface)

Converte uma Surface numa imagem do renderizador ativo.

Source code in src/render.py
120
121
122
def make_image(self, surface: pygame.Surface) -> Image:
    """Converte uma `Surface` numa imagem do renderizador ativo."""
    raise NotImplementedError
present()

Publica o frame montado, levando o canvas logico para a tela real.

Source code in src/render.py
168
169
170
def present(self) -> None:
    """Publica o frame montado, levando o canvas logico para a tela real."""
    raise NotImplementedError
resize(canvas)

Troca o canvas logico, acompanhando a janela que o jogador arrastou (R23.6).

Source code in src/render.py
152
153
154
def resize(self, canvas: tuple[int, int]) -> None:
    """Troca o canvas logico, acompanhando a janela que o jogador arrastou (R23.6)."""
    raise NotImplementedError
snapshot()

Le o canvas de volta como Surface. Diagnostico e testes apenas.

No caminho de GPU e uma transferencia de VRAM para RAM, cara demais para o laco principal. Chamar antes de present().

Limite do caminho de GPU, medido: Renderer.to_surface devolve uma superficie do tamanho logico mas copia os pixels fisicos do alvo, sem desfazer a escala de logical_size. So e fiel quando o canvas tem o tamanho da janela — que e o caso dos testes e do desktop. Com escala diferente de 1 a leitura sai recortada; para conferir um canvas de celular no PC, force a janela com BLOCKY_CANVAS ou use o SurfaceRenderer, cujo snapshot e sempre o canvas inteiro.

Source code in src/render.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def snapshot(self) -> pygame.Surface:
    """Le o canvas de volta como `Surface`. Diagnostico e testes apenas.

    No caminho de GPU e uma transferencia de VRAM para RAM, cara demais para o
    laco principal. Chamar antes de `present()`.

    Limite do caminho de GPU, medido: `Renderer.to_surface` devolve uma superficie
    do tamanho logico mas copia os pixels *fisicos* do alvo, sem desfazer a escala
    de `logical_size`. So e fiel quando o canvas tem o tamanho da janela — que e o
    caso dos testes e do desktop. Com escala diferente de 1 a leitura sai recortada;
    para conferir um canvas de celular no PC, force a janela com `BLOCKY_CANVAS` ou
    use o `SurfaceRenderer`, cujo snapshot e sempre o canvas inteiro.
    """
    raise NotImplementedError
to_logical(x, y)

Converte um ponto em pixels reais da janela para coordenada de canvas.

Os dois caminhos convertem a mesma coisa da mesma forma, e e isso que permite a input.py tratar mouse e toque por um caminho unico (R34.1).

Source code in src/render.py
172
173
174
175
176
177
178
def to_logical(self, x: float, y: float) -> tuple[float, float]:
    """Converte um ponto em pixels reais da janela para coordenada de canvas.

    Os dois caminhos convertem a mesma coisa da mesma forma, e e isso que permite
    a `input.py` tratar mouse e toque por um caminho unico (R34.1).
    """
    raise NotImplementedError

SurfaceImage

Bases: Image

Imagem em memoria principal: uma pygame.Surface, o caminho da v2.

Source code in src/render.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
class SurfaceImage(Image):
    """Imagem em memoria principal: uma `pygame.Surface`, o caminho da v2."""

    __slots__ = ("raw", "size")

    raw: pygame.Surface

    def __init__(self, surface: pygame.Surface) -> None:
        """Envolve uma superficie ja convertida para o formato do display."""
        self.raw = surface
        self.size = surface.get_size()

    @property
    def alpha(self) -> int:
        """Alfa de superficie inteira.

        `None` do pygame vira 255, para que ler o valor logo apos criar a
        imagem devolva o mesmo que no caminho de GPU.
        """
        value = self.raw.get_alpha()
        return _OPAQUE if value is None else value

    @alpha.setter
    def alpha(self, value: int) -> None:
        self.raw.set_alpha(value)
alpha property writable

Alfa de superficie inteira.

None do pygame vira 255, para que ler o valor logo apos criar a imagem devolva o mesmo que no caminho de GPU.

__init__(surface)

Envolve uma superficie ja convertida para o formato do display.

Source code in src/render.py
225
226
227
228
def __init__(self, surface: pygame.Surface) -> None:
    """Envolve uma superficie ja convertida para o formato do display."""
    self.raw = surface
    self.size = surface.get_size()

SurfaceRenderer

Bases: Renderer

Desenho por Surface.blit, o caminho da v2 (design secao 33.4).

E a ultima rede de seguranca da cascata, e tambem o que mantem a suite de testes simples: funciona em qualquer lugar onde o pygame funcione, inclusive sob SDL_VIDEODRIVER=dummy.

O desenho acontece num canvas fora da tela, que present() escala para a janela com barras nas sobras. Nao se usa pygame.SCALED aqui de proposito: com ele o SDL entrega o mouse ja convertido mas o toque nao, e to_logical teria significados diferentes conforme o backend — exatamente a assimetria que a v3 esta desfazendo (R34.1).

Source code in src/render.py
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
class SurfaceRenderer(Renderer):
    """Desenho por `Surface.blit`, o caminho da v2 (design secao 33.4).

    E a ultima rede de seguranca da cascata, e tambem o que mantem a suite de testes
    simples: funciona em qualquer lugar onde o pygame funcione, inclusive sob
    `SDL_VIDEODRIVER=dummy`.

    O desenho acontece num canvas fora da tela, que `present()` escala para a janela
    com barras nas sobras. Nao se usa `pygame.SCALED` aqui de proposito: com ele o
    SDL entrega o mouse ja convertido mas o toque nao, e `to_logical` teria
    significados diferentes conforme o backend — exatamente a assimetria que a v3
    esta desfazendo (R34.1).
    """

    def __init__(self, canvas: tuple[int, int], window: tuple[int, int], *, fullscreen: bool = False) -> None:
        """Cria o display real e o canvas fora da tela onde o jogo desenha."""
        super().__init__()
        self._flags = pygame.FULLSCREEN if fullscreen else pygame.RESIZABLE
        self.window = pygame.display.set_mode(window, self._flags)
        self.surface = pygame.Surface(canvas)
        self.size = canvas
        self.backend = BACKEND_SURFACE
        # superficies reaproveitadas entre frames: recria-las a cada `present()` ou a
        # cada `fill()` com alfa alocaria alguns megabytes por frame, que e
        # exatamente o que a v3 esta eliminando (R27.3).
        self._scaled: pygame.Surface | None = None
        self._blend: pygame.Surface | None = None

    @property
    def window_size(self) -> tuple[int, int]:
        """Tamanho da janela pelo SDL, e nao pela superficie de display.

        `Surface.get_size()` so acompanha o arrasto no proximo `set_mode`;
        `display.get_window_size()` ja reflete o tamanho novo.
        """
        return pygame.display.get_window_size()

    def resize(self, canvas: tuple[int, int]) -> None:
        """Recria o canvas fora da tela e reancora o display na janela arrastada."""
        self.window = pygame.display.set_mode(self.window_size, self._flags)
        self.surface = pygame.Surface(canvas)
        self.size = canvas
        self._scaled = None

    def make_image(self, surface: pygame.Surface) -> Image:
        """Converte para o formato do display antes de guardar (R27.4).

        E aqui que a conversao rende: neste caminho a imagem *e* uma `Surface`, e
        cada `draw` dela e um blit que pagaria conversao de formato se ela nao
        estivesse no formato do display. Superficies que ja chegam convertidas da
        origem (texturas, glifos) passam por aqui de novo, o que e barato e mantem a
        garantia valendo para quem constroi a superficie na hora.
        """
        return SurfaceImage(convert(surface))

    def clear(self, color: Color) -> None:
        """Preenche o canvas inteiro com `color`."""
        self.surface.fill(color)

    def draw(self, image: Image, dest: Dest, area: pygame.Rect | None = None) -> None:
        """Desenha a superficie, escalando so quando `dest` pede outro tamanho."""
        rect = _dest_rect(dest, image, area)
        source = image.raw
        if rect.size != (area.size if area is not None else image.size):
            # o caminho de GPU estica a textura para o `dstrect`; aqui isso precisa
            # ser feito a mao, para que os dois backends desenhem a mesma coisa.
            source = pygame.transform.scale(source.subsurface(area) if area else source, rect.size)
            area = None
        self.surface.blit(source, rect, area)

    def fill(self, color: Color, rect: pygame.Rect) -> None:
        """Preenche `rect`, misturando quando a cor tem alfa.

        A superficie de mistura e cacheada por tamanho: o escurecimento de PAUSADO e
        de GAME_OVER cobre o canvas inteiro todo frame, e aloca-la a cada um era
        justamente a maior fonte de lixo por frame da v2 (design secao 30).
        """
        if not _has_alpha(color):
            self.surface.fill(color, rect)
            return
        self.surface.blit(self._blend_surface(rect.size, color), rect)

    def present(self) -> None:
        """Escala o canvas para a janela e publica o frame."""
        factor, offset_x, offset_y = scale.fit_scale(*self.size, *self.window.get_size())
        target = (round(self.size[0] * factor), round(self.size[1] * factor))
        if self._scaled is None or self._scaled.get_size() != target:
            self._scaled = pygame.Surface(target)
        # `scale` e vizinho mais proximo, ao contrario de `smoothscale`: e o mesmo
        # criterio do hint de qualidade do caminho de GPU (R26.7).
        pygame.transform.scale(self.surface, target, self._scaled)
        if offset_x or offset_y:
            self.window.fill((0, 0, 0))
        self.window.blit(self._scaled, (round(offset_x), round(offset_y)))
        pygame.display.flip()

    def to_logical(self, x: float, y: float) -> tuple[float, float]:
        """Desfaz a escala e o deslocamento aplicados por `present()`."""
        factor, offset_x, offset_y = scale.fit_scale(*self.size, *self.window.get_size())
        return (x - offset_x) / factor, (y - offset_y) / factor

    def snapshot(self) -> pygame.Surface:
        """Copia o canvas fora da tela."""
        return self.surface.copy()

    def _blend_surface(self, size: tuple[int, int], color: Color) -> pygame.Surface:
        """Superficie de mistura do tamanho pedido, reaproveitada entre chamadas."""
        if self._blend is None or self._blend.get_size() != size:
            self._blend = pygame.Surface(size, pygame.SRCALPHA)
        self._blend.fill(color)
        return self._blend
window_size property

Tamanho da janela pelo SDL, e nao pela superficie de display.

Surface.get_size() so acompanha o arrasto no proximo set_mode; display.get_window_size() ja reflete o tamanho novo.

__init__(canvas, window, *, fullscreen=False)

Cria o display real e o canvas fora da tela onde o jogo desenha.

Source code in src/render.py
336
337
338
339
340
341
342
343
344
345
346
347
348
def __init__(self, canvas: tuple[int, int], window: tuple[int, int], *, fullscreen: bool = False) -> None:
    """Cria o display real e o canvas fora da tela onde o jogo desenha."""
    super().__init__()
    self._flags = pygame.FULLSCREEN if fullscreen else pygame.RESIZABLE
    self.window = pygame.display.set_mode(window, self._flags)
    self.surface = pygame.Surface(canvas)
    self.size = canvas
    self.backend = BACKEND_SURFACE
    # superficies reaproveitadas entre frames: recria-las a cada `present()` ou a
    # cada `fill()` com alfa alocaria alguns megabytes por frame, que e
    # exatamente o que a v3 esta eliminando (R27.3).
    self._scaled: pygame.Surface | None = None
    self._blend: pygame.Surface | None = None
clear(color)

Preenche o canvas inteiro com color.

Source code in src/render.py
377
378
379
def clear(self, color: Color) -> None:
    """Preenche o canvas inteiro com `color`."""
    self.surface.fill(color)
draw(image, dest, area=None)

Desenha a superficie, escalando so quando dest pede outro tamanho.

Source code in src/render.py
381
382
383
384
385
386
387
388
389
390
def draw(self, image: Image, dest: Dest, area: pygame.Rect | None = None) -> None:
    """Desenha a superficie, escalando so quando `dest` pede outro tamanho."""
    rect = _dest_rect(dest, image, area)
    source = image.raw
    if rect.size != (area.size if area is not None else image.size):
        # o caminho de GPU estica a textura para o `dstrect`; aqui isso precisa
        # ser feito a mao, para que os dois backends desenhem a mesma coisa.
        source = pygame.transform.scale(source.subsurface(area) if area else source, rect.size)
        area = None
    self.surface.blit(source, rect, area)
fill(color, rect)

Preenche rect, misturando quando a cor tem alfa.

A superficie de mistura e cacheada por tamanho: o escurecimento de PAUSADO e de GAME_OVER cobre o canvas inteiro todo frame, e aloca-la a cada um era justamente a maior fonte de lixo por frame da v2 (design secao 30).

Source code in src/render.py
392
393
394
395
396
397
398
399
400
401
402
def fill(self, color: Color, rect: pygame.Rect) -> None:
    """Preenche `rect`, misturando quando a cor tem alfa.

    A superficie de mistura e cacheada por tamanho: o escurecimento de PAUSADO e
    de GAME_OVER cobre o canvas inteiro todo frame, e aloca-la a cada um era
    justamente a maior fonte de lixo por frame da v2 (design secao 30).
    """
    if not _has_alpha(color):
        self.surface.fill(color, rect)
        return
    self.surface.blit(self._blend_surface(rect.size, color), rect)
make_image(surface)

Converte para o formato do display antes de guardar (R27.4).

E aqui que a conversao rende: neste caminho a imagem e uma Surface, e cada draw dela e um blit que pagaria conversao de formato se ela nao estivesse no formato do display. Superficies que ja chegam convertidas da origem (texturas, glifos) passam por aqui de novo, o que e barato e mantem a garantia valendo para quem constroi a superficie na hora.

Source code in src/render.py
366
367
368
369
370
371
372
373
374
375
def make_image(self, surface: pygame.Surface) -> Image:
    """Converte para o formato do display antes de guardar (R27.4).

    E aqui que a conversao rende: neste caminho a imagem *e* uma `Surface`, e
    cada `draw` dela e um blit que pagaria conversao de formato se ela nao
    estivesse no formato do display. Superficies que ja chegam convertidas da
    origem (texturas, glifos) passam por aqui de novo, o que e barato e mantem a
    garantia valendo para quem constroi a superficie na hora.
    """
    return SurfaceImage(convert(surface))
present()

Escala o canvas para a janela e publica o frame.

Source code in src/render.py
404
405
406
407
408
409
410
411
412
413
414
415
416
def present(self) -> None:
    """Escala o canvas para a janela e publica o frame."""
    factor, offset_x, offset_y = scale.fit_scale(*self.size, *self.window.get_size())
    target = (round(self.size[0] * factor), round(self.size[1] * factor))
    if self._scaled is None or self._scaled.get_size() != target:
        self._scaled = pygame.Surface(target)
    # `scale` e vizinho mais proximo, ao contrario de `smoothscale`: e o mesmo
    # criterio do hint de qualidade do caminho de GPU (R26.7).
    pygame.transform.scale(self.surface, target, self._scaled)
    if offset_x or offset_y:
        self.window.fill((0, 0, 0))
    self.window.blit(self._scaled, (round(offset_x), round(offset_y)))
    pygame.display.flip()
resize(canvas)

Recria o canvas fora da tela e reancora o display na janela arrastada.

Source code in src/render.py
359
360
361
362
363
364
def resize(self, canvas: tuple[int, int]) -> None:
    """Recria o canvas fora da tela e reancora o display na janela arrastada."""
    self.window = pygame.display.set_mode(self.window_size, self._flags)
    self.surface = pygame.Surface(canvas)
    self.size = canvas
    self._scaled = None
snapshot()

Copia o canvas fora da tela.

Source code in src/render.py
423
424
425
def snapshot(self) -> pygame.Surface:
    """Copia o canvas fora da tela."""
    return self.surface.copy()
to_logical(x, y)

Desfaz a escala e o deslocamento aplicados por present().

Source code in src/render.py
418
419
420
421
def to_logical(self, x: float, y: float) -> tuple[float, float]:
    """Desfaz a escala e o deslocamento aplicados por `present()`."""
    factor, offset_x, offset_y = scale.fit_scale(*self.size, *self.window.get_size())
    return (x - offset_x) / factor, (y - offset_y) / factor

convert(surface)

Devolve surface no formato de pixel do display (R27.4).

Toda superficie nasce no formato padrao do pygame, que nao e necessariamente o do display. Blitar uma superficie de formato diferente faz o SDL converter pixel a pixel, a cada blit; converte-la uma vez, na inicializacao, paga esse custo de uma vez so. Por isso a conversao acontece na origem — em textures.generate_all e no cache de pixelfont.render — e nao apenas aqui na hora de virar imagem.

convert_alpha so para quem tem alfa por pixel, e convert para o resto: convert() numa superficie SRCALPHA descartaria a transparencia (as asas da abelha, o vazado dos glifos), e convert_alpha() numa superficie opaca acrescentaria um canal alfa que so faria o blit passar pelo caminho de mistura sem necessidade.

Sem formato de display definido a conversao levanta pygame.error. E condicao real em dois lugares: nos testes que rodam antes de qualquer set_mode, e no caminho de GPU, que nunca chama display.set_mode porque a janela vem do _sdl2 — e onde a conversao seria inocua de qualquer forma, ja que Texture.from_surface converte no envio. A superficie original serve, so mais lenta: nunca vale impedir o jogo de abrir por causa de uma otimizacao.

Source code in src/render.py
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
def convert(surface: pygame.Surface) -> pygame.Surface:
    """Devolve `surface` no formato de pixel do display (R27.4).

    Toda superficie nasce no formato padrao do pygame, que nao e necessariamente o do
    display. Blitar uma superficie de formato diferente faz o SDL converter pixel a
    pixel, a cada blit; converte-la uma vez, na inicializacao, paga esse custo de uma
    vez so. Por isso a conversao acontece na origem — em `textures.generate_all` e no
    cache de `pixelfont.render` — e nao apenas aqui na hora de virar imagem.

    `convert_alpha` so para quem tem alfa por pixel, e `convert` para o resto:
    `convert()` numa superficie SRCALPHA descartaria a transparencia (as asas da
    abelha, o vazado dos glifos), e `convert_alpha()` numa superficie opaca
    acrescentaria um canal alfa que so faria o blit passar pelo caminho de mistura
    sem necessidade.

    Sem formato de display definido a conversao levanta `pygame.error`. E condicao
    real em dois lugares: nos testes que rodam antes de qualquer `set_mode`, e no
    caminho de GPU, que nunca chama `display.set_mode` porque a janela vem do
    `_sdl2` — e onde a conversao seria inocua de qualquer forma, ja que
    `Texture.from_surface` converte no envio. A superficie original serve, so mais
    lenta: nunca vale impedir o jogo de abrir por causa de uma otimizacao.
    """
    try:
        if surface.get_flags() & pygame.SRCALPHA:
            return surface.convert_alpha()
        return surface.convert()
    except pygame.error:
        return surface

create(canvas, window, *, fullscreen=False, title='', icon=None)

Cria o melhor renderizador disponivel para este aparelho (R26.1, R26.2, R26.3).

Desce a cascata de tres niveis e devolve o primeiro que funcionar, registrando em log o erro de cada queda (R26.5). Nunca levanta excecao: o ultimo nivel e o caminho de superficie da v2, que funciona em qualquer lugar onde o pygame funcione.

O icon entra por aqui porque cada caminho o define de um jeito — pela Window do _sdl2 ou pelo modulo display —, e quem chama nao deve precisar saber qual.

Os dois hints do SDL sao definidos aqui, e nao no Game, porque e aqui que o renderizador nasce — o SDL le os dois na criacao dele, e um hint definido depois nao tem efeito nenhum. setdefault nos dois: investigar outro valor continua sendo questao de exportar a variavel, sem editar codigo.

Source code in src/render.py
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
def create(
    canvas: tuple[int, int],
    window: tuple[int, int],
    *,
    fullscreen: bool = False,
    title: str = "",
    icon: pygame.Surface | None = None,
) -> Renderer:
    """Cria o melhor renderizador disponivel para este aparelho (R26.1, R26.2, R26.3).

    Desce a cascata de tres niveis e devolve o primeiro que funcionar, registrando em
    log o erro de cada queda (R26.5). Nunca levanta excecao: o ultimo nivel e o
    caminho de superficie da v2, que funciona em qualquer lugar onde o pygame
    funcione.

    O `icon` entra por aqui porque cada caminho o define de um jeito — pela `Window`
    do `_sdl2` ou pelo modulo `display` —, e quem chama nao deve precisar saber qual.

    Os dois hints do SDL sao definidos aqui, e nao no `Game`, porque e aqui que o
    renderizador nasce — o SDL le os dois na criacao dele, e um hint definido depois
    nao tem efeito nenhum. `setdefault` nos dois: investigar outro valor continua
    sendo questao de exportar a variavel, sem editar codigo.
    """
    os.environ.setdefault(ENV_SCALE_QUALITY, "0")
    os.environ.setdefault(ENV_RENDER_BATCHING, "1")

    sdl_window = _open_sdl_window(window, fullscreen=fullscreen, title=title)
    if sdl_window is not None:
        for accelerated, backend in ((1, BACKEND_ACCELERATED), (-1, BACKEND_SOFTWARE)):
            try:
                sdl_renderer = video.Renderer(sdl_window, accelerated=accelerated, vsync=True)
            except Exception as exc:
                logger.warning("renderizador %s indisponivel: %s", backend, exc)
                continue
            if icon is not None:
                sdl_window.set_icon(icon)
            return GpuRenderer(sdl_window, sdl_renderer, canvas, backend)
        sdl_window.destroy()

    logger.warning("sem renderizador de GPU; caindo para o caminho %s", BACKEND_SURFACE)
    if icon is not None:
        pygame.display.set_icon(icon)
    pygame.display.set_caption(title)
    return SurfaceRenderer(canvas, window, fullscreen=fullscreen)

src.scale

src.scale

Calcula a escala/letterbox que ajusta a resolucao logica fixa a uma janela real.

Nunca corta a imagem (R14.3). Ao contrario do zoom/corte (cover: cresce ate cobrir a janela inteira, cortando o excedente de um dos eixos — usado ate a task 40), aqui a escala usada e sempre a MENOR das duas (fit: encolhe ate caber inteiro na janela), sobrando barra (letterbox/pillarbox) no eixo que nao bate — a mesma conta que pygame.SCALED ja faz internamente para o mouse; FINGERDOWN chega sem essa conversao pronta, entao input.py reaproveita esta funcao para desfaze-la (task 41).

fit_scale(canvas_w, canvas_h, window_w, window_h)

Calcula a escala e os offsets de letterbox de um canvas numa janela.

Retorna (escala, offset_x, offset_y) para desenhar um canvas canvas_w x canvas_h inteiro dentro de uma janela window_w x window_h, sem cortar nada. Os offsets sao sempre >= 0 (a barra sobra, centralizada, nunca falta). Dados invalidos devolvem escala 1 sem deslocamento (nunca deveria ocorrer com uma janela real).

Source code in src/scale.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def fit_scale(canvas_w: int, canvas_h: int, window_w: int, window_h: int) -> tuple[float, float, float]:
    """Calcula a escala e os offsets de letterbox de um canvas numa janela.

    Retorna `(escala, offset_x, offset_y)` para desenhar um canvas
    `canvas_w x canvas_h` inteiro dentro de uma janela `window_w x window_h`,
    sem cortar nada. Os offsets sao sempre >= 0 (a barra sobra, centralizada,
    nunca falta). Dados invalidos devolvem escala 1 sem deslocamento (nunca
    deveria ocorrer com uma janela real).
    """
    if canvas_w <= 0 or canvas_h <= 0 or window_w <= 0 or window_h <= 0:
        return 1.0, 0.0, 0.0

    scale = min(window_w / canvas_w, window_h / canvas_h)
    offset_x = (window_w - canvas_w * scale) / 2
    offset_y = (window_h - canvas_h * scale) / 2
    return scale, offset_x, offset_y

src.score

src.score

Persistencia do recorde em highscore.json (R4.3, R4.4, R4.5).

load_highscore(path=None)

Le o recorde salvo, ou 0 se o arquivo faltar ou estiver corrompido.

Source code in src/score.py
19
20
21
22
23
24
25
26
27
def load_highscore(path: Path | None = None) -> int:
    """Le o recorde salvo, ou 0 se o arquivo faltar ou estiver corrompido."""
    path = path if path is not None else _default_path()
    try:
        with open(path, encoding="utf-8") as f:
            data = json.load(f)
        return int(data["highscore"])
    except (OSError, ValueError, KeyError, TypeError):
        return 0

save_highscore(highscore, path=None)

Grava o recorde em disco, ignorando falha de escrita em silencio.

Source code in src/score.py
30
31
32
33
34
35
36
37
def save_highscore(highscore: int, path: Path | None = None) -> None:
    """Grava o recorde em disco, ignorando falha de escrita em silencio."""
    path = path if path is not None else _default_path()
    try:
        with open(path, "w", encoding="utf-8") as f:
            json.dump({"highscore": highscore}, f)
    except OSError:
        pass

src.sounds

src.sounds

Sintese de sons 8-bit via stdlib (sem numpy, R9.2) e controle de mudo (R8).

MixerParams

Bases: TypedDict

Argumentos nomeados de pygame.mixer.init/pre_init que o jogo define.

Tipado, e nao um dict[str, int], para que o desempacotamento nas duas chamadas seja conferido pelo ty contra a assinatura real do pygame — que tem tambem parametros de outros tipos (devicename).

Source code in src/sounds.py
16
17
18
19
20
21
22
23
24
25
26
27
class MixerParams(TypedDict, total=False):
    """Argumentos nomeados de `pygame.mixer.init`/`pre_init` que o jogo define.

    Tipado, e nao um `dict[str, int]`, para que o desempacotamento nas duas chamadas
    seja conferido pelo `ty` contra a assinatura real do pygame — que tem tambem
    parametros de outros tipos (`devicename`).
    """

    frequency: int
    size: int
    channels: int
    buffer: int

SoundManager

Gera e toca os efeitos sonoros sintetizados, com mudo e degradacao graciosa.

Source code in src/sounds.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
class SoundManager:
    """Gera e toca os efeitos sonoros sintetizados, com mudo e degradacao graciosa."""

    def __init__(self) -> None:
        """Assume o mixer que `pre_init` + `pygame.init()` ja deixaram pronto.

        So inicializa por conta propria se ele nao estiver de pe — o que acontece quando
        alguem constroi o `SoundManager` fora do jogo (um teste, um script) e quando o
        `pygame.init()` nao conseguiu abrir o dispositivo. No segundo caso a tentativa
        aqui tambem falha, e o jogo segue mudo, como desde a v1 (R8.4).
        """
        self.muted = False
        self.audio_ok = False
        try:
            if pygame.mixer.get_init() is None:
                pygame.mixer.init(**mixer_params())
            self.audio_ok = True
        except pygame.error:
            self.audio_ok = False

        self._sounds: dict[str, pygame.mixer.Sound] = {}
        if self.audio_ok:
            self._sounds = {
                "flap": pygame.mixer.Sound(buffer=_square_sweep(300, 500, 80)),
                "score": pygame.mixer.Sound(buffer=_double_ping(800, 1200, 120)),
                "hit": pygame.mixer.Sound(buffer=_white_noise(200)),
                "portal": pygame.mixer.Sound(buffer=_square_sweep(900, 200, 400)),
            }

    def play(self, name: str) -> None:
        """Toca o som se o audio estiver disponivel e nao estiver mudo (R8.3, R8.4)."""
        if self.audio_ok and not self.muted:
            self._sounds[name].play()

    def toggle_mute(self) -> None:
        """Liga/desliga o mudo (R8.3)."""
        self.muted = not self.muted
__init__()

Assume o mixer que pre_init + pygame.init() ja deixaram pronto.

So inicializa por conta propria se ele nao estiver de pe — o que acontece quando alguem constroi o SoundManager fora do jogo (um teste, um script) e quando o pygame.init() nao conseguiu abrir o dispositivo. No segundo caso a tentativa aqui tambem falha, e o jogo segue mudo, como desde a v1 (R8.4).

Source code in src/sounds.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
def __init__(self) -> None:
    """Assume o mixer que `pre_init` + `pygame.init()` ja deixaram pronto.

    So inicializa por conta propria se ele nao estiver de pe — o que acontece quando
    alguem constroi o `SoundManager` fora do jogo (um teste, um script) e quando o
    `pygame.init()` nao conseguiu abrir o dispositivo. No segundo caso a tentativa
    aqui tambem falha, e o jogo segue mudo, como desde a v1 (R8.4).
    """
    self.muted = False
    self.audio_ok = False
    try:
        if pygame.mixer.get_init() is None:
            pygame.mixer.init(**mixer_params())
        self.audio_ok = True
    except pygame.error:
        self.audio_ok = False

    self._sounds: dict[str, pygame.mixer.Sound] = {}
    if self.audio_ok:
        self._sounds = {
            "flap": pygame.mixer.Sound(buffer=_square_sweep(300, 500, 80)),
            "score": pygame.mixer.Sound(buffer=_double_ping(800, 1200, 120)),
            "hit": pygame.mixer.Sound(buffer=_white_noise(200)),
            "portal": pygame.mixer.Sound(buffer=_square_sweep(900, 200, 400)),
        }
play(name)

Toca o som se o audio estiver disponivel e nao estiver mudo (R8.3, R8.4).

Source code in src/sounds.py
134
135
136
137
def play(self, name: str) -> None:
    """Toca o som se o audio estiver disponivel e nao estiver mudo (R8.3, R8.4)."""
    if self.audio_ok and not self.muted:
        self._sounds[name].play()
toggle_mute()

Liga/desliga o mudo (R8.3).

Source code in src/sounds.py
139
140
141
def toggle_mute(self) -> None:
    """Liga/desliga o mudo (R8.3)."""
    self.muted = not self.muted

mixer_params()

Parametros do mixer: 44.1 kHz, 16 bits com sinal, mono.

O buffer maior so no Android, onde o padrao do pygame e pequeno demais para o caminho de audio do aparelho e o som sai crepitando (R8.4, R14.6, design secao 11). No desktop a chave nem e passada, para continuar valendo o padrao do pygame.

Source code in src/sounds.py
30
31
32
33
34
35
36
37
38
39
40
def mixer_params() -> MixerParams:
    """Parametros do mixer: 44.1 kHz, 16 bits com sinal, mono.

    O buffer maior so no Android, onde o padrao do pygame e pequeno demais para o
    caminho de audio do aparelho e o som sai crepitando (R8.4, R14.6, design secao 11).
    No desktop a chave nem e passada, para continuar valendo o padrao do pygame.
    """
    params: MixerParams = {"frequency": SAMPLE_RATE, "size": -16, "channels": 1}
    if is_android():
        params["buffer"] = ANDROID_MIXER_BUFFER
    return params

pre_init()

Fixa os parametros do mixer ANTES de pygame.init() (R27.6, design secao 40).

A v2 inicializava o mixer duas vezes: pygame.init() o subia com os parametros padrao e, logo em seguida, SoundManager.__init__ o derrubava e o subia de novo com os parametros certos. Abrir um dispositivo de audio e caro — no Android, dezenas de milissegundos —, e fazer isso duas vezes na abertura era tempo de inicializacao jogado fora, alem de expor uma janela em que o dispositivo esta aberto com o buffer errado.

pre_init nao abre nada: so guarda o que o init seguinte vai usar. Por isso e inofensiva quando o audio nao existe — a falha continua acontecendo no init, onde ja e tratada.

Source code in src/sounds.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def pre_init() -> None:
    """Fixa os parametros do mixer ANTES de `pygame.init()` (R27.6, design secao 40).

    A v2 inicializava o mixer duas vezes: `pygame.init()` o subia com os parametros
    padrao e, logo em seguida, `SoundManager.__init__` o derrubava e o subia de novo
    com os parametros certos. Abrir um dispositivo de audio e caro — no Android, dezenas
    de milissegundos —, e fazer isso duas vezes na abertura era tempo de inicializacao
    jogado fora, alem de expor uma janela em que o dispositivo esta aberto com o buffer
    errado.

    `pre_init` nao abre nada: so guarda o que o `init` seguinte vai usar. Por isso e
    inofensiva quando o audio nao existe — a falha continua acontecendo no `init`, onde
    ja e tratada.
    """
    pygame.mixer.pre_init(**mixer_params())

src.storage

src.storage

Resolve o diretorio gravavel do highscore por plataforma (R4.5).

No Android o diretorio de trabalho nao e gravavel; a v1 gravava highscore.json via caminho relativo (resolvido contra o cwd), o que falharia silenciosamente la (a escrita ja engole OSError). Este modulo resolve um diretorio correto por plataforma para score.py usar como default.

is_android()

ANDROID_ARGUMENT e definido pelo python-for-android em tempo de execucao.

Source code in src/storage.py
14
15
16
def is_android() -> bool:
    """ANDROID_ARGUMENT e definido pelo python-for-android em tempo de execucao."""
    return "ANDROID_ARGUMENT" in os.environ

is_frozen()

PyInstaller define sys.frozen no executavel empacotado (BlockyBee.spec).

No modo onefile, file aponta para o diretorio temporario de extracao (sys._MEIPASS), apagado ao fechar o app — usar esse caminho como base faz o highscore.json nunca sobreviver entre execucoes do .exe/binario.

Source code in src/storage.py
19
20
21
22
23
24
25
26
def is_frozen() -> bool:
    """PyInstaller define sys.frozen no executavel empacotado (BlockyBee.spec).

    No modo onefile, __file__ aponta para o diretorio temporario de extracao
    (sys._MEIPASS), apagado ao fechar o app — usar esse caminho como base faz
    o highscore.json nunca sobreviver entre execucoes do .exe/binario.
    """
    return getattr(sys, "frozen", False)

save_dir()

Diretorio gravavel para dados persistentes (recorde, qualidade), por plataforma.

Source code in src/storage.py
29
30
31
32
33
34
35
36
37
38
39
40
41
def save_dir() -> Path:
    """Diretorio gravavel para dados persistentes (recorde, qualidade), por plataforma."""
    if is_android():
        try:
            # fornecido pelo p4a em runtime; indisponivel no venv de dev
            from android.storage import app_storage_path  # ty: ignore[unresolved-import]

            return Path(app_storage_path())
        except ImportError:
            return Path(os.environ.get("ANDROID_PRIVATE", "."))
    if is_frozen():
        return Path(sys.executable).resolve().parent  # pasta do .exe/binario empacotado
    return Path(__file__).resolve().parent.parent  # raiz do projeto, rodando de fonte

src.textures

src.textures

Geracao procedural de texturas voxel (blocos 16x16 escalados).

Box = tuple[tuple[int, int, int], int, int, int, int] module-attribute

Um cubo de um sprite: a cor e o retangulo (x, y, largura, altura) dentro dos 16x16.

MOB_MAKERS = {'creeper': make_creeper, 'witch': make_witch, 'villager': make_villager, 'enderman': make_enderman, 'spider': make_spider, 'skeleton': make_skeleton, 'ghast': make_ghast, 'blaze': make_blaze, 'piglin': make_piglin} module-attribute

Os nove mobs decorativos de R25.3, todos gerados por codigo — nenhuma imagem externa e nenhum material de terceiros, a mesma disciplina dos blocos (R7.1).

Quem distribui e desenha e mobs.py; aqui ficam so as formas, porque este e o modulo que ja sabia pintar voxel e porque assim mobs.py nao depende de nenhum modulo de jogo (R25.4).

generate_all(block_size)

Gera todas as texturas base 16x16 e escala pixel-perfect para block_size (R7.1).

Cada textura sai ja no formato de pixel do display (R27.4). Estas sao as superficies mais reusadas do jogo — a mesma pedra vira dezenas de blocos de coluna por frame, e alimenta tambem as faixas laterais e os sprites rotacionados da abelha —, entao converte-las na geracao vale por todos esses usos de uma vez.

Source code in src/textures.py
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
def generate_all(block_size: int) -> dict[str, pygame.Surface]:
    """Gera todas as texturas base 16x16 e escala pixel-perfect para block_size (R7.1).

    Cada textura sai ja no formato de pixel do display (R27.4). Estas sao as
    superficies mais reusadas do jogo — a mesma pedra vira dezenas de blocos de
    coluna por frame, e alimenta tambem as faixas laterais e os sprites rotacionados
    da abelha —, entao converte-las na geracao vale por todos esses usos de uma vez.
    """
    raw = {
        "dirt": make_dirt(),
        "grass_side": make_grass_side(),
        "stone": make_stone(),
        "cobblestone": make_cobblestone(),
        "netherrack": make_netherrack(),
        "obsidian": make_obsidian(),
        "bee_0": make_bee(0),
        "bee_1": make_bee(1),
    }
    return {name: convert(scale_pixel_perfect(surf, block_size)) for name, surf in raw.items()}

make_bee(frame=0)

Abelha voxel: corpo amarelo com listras pretas, asas em 2 frames (R7.2).

Source code in src/textures.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def make_bee(frame: int = 0) -> pygame.Surface:
    """Abelha voxel: corpo amarelo com listras pretas, asas em 2 frames (R7.2)."""
    surf = pygame.Surface((TEX_SIZE, TEX_SIZE), pygame.SRCALPHA)
    body = (240, 200, 30)
    stripe = (30, 24, 10)
    wing = (220, 230, 235, 180)

    body_rows = range(4, 12)
    for y in body_rows:
        for x in range(3, 13):
            surf.set_at((x, y), body)

    for y in body_rows:
        for x in (4, 5, 8, 9):
            surf.set_at((x, y), stripe)

    for x in range(3, 13):
        surf.set_at((x, 4), stripe)
        surf.set_at((x, 11), stripe)

    wing_rows = range(2, 6) if frame == 0 else range(4, 8)
    for y in wing_rows:
        for x in range(9, 14):
            surf.set_at((x, y), wing)

    return surf

make_blaze(frame=0)

Blaze: nucleo em brasa cercado pelas varas, que giram entre os dois frames.

Source code in src/textures.py
286
287
288
289
290
291
292
293
294
295
296
297
def make_blaze(frame: int = 0) -> pygame.Surface:
    """Blaze: nucleo em brasa cercado pelas varas, que giram entre os dois frames."""
    rods = ((2, 5), (12, 4), (4, 11), (11, 10)) if frame == 0 else ((3, 3), (11, 6), (2, 9), (12, 12))
    boxes: list[Box] = [(BLAZE_ROD, x, y, 2, 3) for x, y in rods]
    boxes += [
        (BLAZE_CORE, 5, 2, 6, 5),
        (BLAZE_FACE, 6, 4, 1, 1),
        (BLAZE_FACE, 9, 4, 1, 1),
        (BLAZE_ROD, 5, 7, 6, 5),
        (BLAZE_CORE, 6, 8, 4, 3),
    ]
    return _voxel(boxes)

make_cobblestone(seed=4)

Textura de paralelepipedo, 16x16.

Source code in src/textures.py
46
47
48
def make_cobblestone(seed: int = 4) -> pygame.Surface:
    """Textura de paralelepipedo, 16x16."""
    return _shaded((122, 122, 122), seed, variation=0.22)

make_creeper(frame=0)

Creeper: cabeca quadrada com a face mais reconhecivel do jogo, quatro patas.

Source code in src/textures.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def make_creeper(frame: int = 0) -> pygame.Surface:
    """Creeper: cabeca quadrada com a face mais reconhecivel do jogo, quatro patas."""
    front, back = (4, 3) if frame == 0 else (3, 4)
    return _voxel(
        [
            (CREEPER_SKIN, 4, 0, 8, 8),
            (CREEPER_FACE, 5, 2, 2, 2),
            (CREEPER_FACE, 9, 2, 2, 2),
            (CREEPER_FACE, 7, 4, 2, 3),  # boca central
            (CREEPER_FACE, 6, 5, 1, 2),  # presas
            (CREEPER_FACE, 9, 5, 1, 2),
            (CREEPER_SKIN, 5, 8, 6, 4),
            (CREEPER_DARK, 5, 9, 2, 2),
            (CREEPER_DARK, 9, 10, 2, 2),
            (CREEPER_SKIN, 4, 12, 3, front),  # patas alternam no idle
            (CREEPER_SKIN, 9, 12, 3, back),
        ]
    )

make_dirt(seed=1)

Textura de terra, 16x16.

Source code in src/textures.py
24
25
26
def make_dirt(seed: int = 1) -> pygame.Surface:
    """Textura de terra, 16x16."""
    return _shaded((134, 96, 67), seed)

make_enderman(frame=0)

Enderman: silhueta preta e esticada, com os olhos roxos como unica cor.

Source code in src/textures.py
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
def make_enderman(frame: int = 0) -> pygame.Surface:
    """Enderman: silhueta preta e esticada, com os olhos roxos como unica cor."""
    reach = frame
    return _voxel(
        [
            (ENDER_BODY, 5, 0, 6, 4),
            (ENDER_EYE, 5, 2, 2, 1),
            (ENDER_EYE, 9, 2, 2, 1),
            (ENDER_BODY, 6, 4, 4, 5),
            (ENDER_BODY, 4, 4, 1, 6 + reach),  # bracos compridos
            (ENDER_BODY, 11, 4, 1, 6 + reach),
            (ENDER_BODY, 6, 9, 1, 7),
            (ENDER_BODY, 9, 9, 1, 7),
        ]
    )

make_ghast(frame=0)

Ghast: cubo branco flutuante com os tentaculos ondulando embaixo.

Source code in src/textures.py
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
def make_ghast(frame: int = 0) -> pygame.Surface:
    """Ghast: cubo branco flutuante com os tentaculos ondulando embaixo."""
    tail = frame
    return _voxel(
        [
            (GHAST_BODY, 3, 1, 10, 9),
            (GHAST_SHADE, 3, 8, 10, 1),
            (GHAST_FACE, 5, 4, 2, 2),
            (GHAST_FACE, 9, 4, 2, 2),
            (GHAST_FACE, 6, 7, 4, 1),
            (GHAST_BODY, 4, 10, 2, 4 + tail),  # tentaculos ondulam
            (GHAST_BODY, 7, 10, 2, 6 - tail),
            (GHAST_BODY, 10, 10, 2, 3 + tail),
        ]
    )

make_grass_side(seed=2)

Textura de terra com uma faixa de grama no topo, 16x16.

Source code in src/textures.py
29
30
31
32
33
34
35
36
37
38
def make_grass_side(seed: int = 2) -> pygame.Surface:
    """Textura de terra com uma faixa de grama no topo, 16x16."""
    surf = make_dirt(seed)
    rng = random.Random(seed + 1)
    for x in range(TEX_SIZE):
        for y in range(3):
            factor = 1 + rng.uniform(-0.12, 0.12)
            color = tuple(max(0, min(255, round(c * factor))) for c in (95, 159, 53))
            surf.set_at((x, y), color)
    return surf

make_netherrack(seed=5)

Textura de netherrack, 16x16.

Source code in src/textures.py
51
52
53
def make_netherrack(seed: int = 5) -> pygame.Surface:
    """Textura de netherrack, 16x16."""
    return _shaded((110, 54, 48), seed, variation=0.18)

make_obsidian(seed=6)

Textura de obsidiana com veios roxos, 16x16.

Source code in src/textures.py
56
57
58
59
60
61
62
63
64
65
def make_obsidian(seed: int = 6) -> pygame.Surface:
    """Textura de obsidiana com veios roxos, 16x16."""
    surf = _shaded((20, 16, 34), seed, variation=0.15)
    rng = random.Random(seed + 1)
    for _ in range(6):
        x = rng.randrange(TEX_SIZE)
        y = rng.randrange(TEX_SIZE)
        purple = tuple(max(0, min(255, c + rng.randint(20, 60))) for c in (60, 30, 90))
        surf.set_at((x, y), purple)
    return surf

make_piglin(frame=0)

Piglin: focinho, orelhas de fora e o peitoral dourado.

Source code in src/textures.py
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
def make_piglin(frame: int = 0) -> pygame.Surface:
    """Piglin: focinho, orelhas de fora e o peitoral dourado."""
    step = frame
    return _voxel(
        [
            (PIGLIN_SKIN, 5, 1, 6, 5),
            (PIGLIN_SKIN, 3, 2, 2, 2),  # orelhas
            (PIGLIN_SKIN, 11, 2, 2, 2),
            ((48, 32, 30), 6, 2, 1, 1),
            ((48, 32, 30), 9, 2, 1, 1),
            (PIGLIN_SNOUT, 6, 4, 4, 2),
            (PIGLIN_TUNIC, 5, 6, 6, 5),
            (PIGLIN_GOLD, 5, 6, 6, 1),
            (PIGLIN_SKIN, 4, 7, 1, 4),
            (PIGLIN_SKIN, 11, 7, 1, 4),
            (PIGLIN_TUNIC, 5, 11, 2, 4 + step),  # pernas alternam
            (PIGLIN_TUNIC, 9, 11, 2, 5 - step),
        ]
    )

make_skeleton(frame=0)

Esqueleto: cranio com as orbitas vazias, costelas e membros finos.

Source code in src/textures.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
def make_skeleton(frame: int = 0) -> pygame.Surface:
    """Esqueleto: cranio com as orbitas vazias, costelas e membros finos."""
    lift = frame
    return _voxel(
        [
            (BONE, 5, 0, 6, 5),
            (SKULL_EYE, 6, 2, 1, 2),
            (SKULL_EYE, 9, 2, 1, 2),
            (BONE_DARK, 7, 4, 2, 1),  # maxilar
            (BONE, 6, 5, 4, 6),
            (BONE_DARK, 6, 7, 4, 1),  # costelas
            (BONE_DARK, 6, 9, 4, 1),
            (BONE, 5, 5 + lift, 1, 5),  # bracos sobem e descem
            (BONE, 10, 5 + lift, 1, 5),
            (BONE, 6, 11, 1, 5),
            (BONE, 9, 11, 1, 5),
        ]
    )

make_spider(frame=0)

Aranha: corpo baixo, oito patas e os olhos vermelhos.

Source code in src/textures.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def make_spider(frame: int = 0) -> pygame.Surface:
    """Aranha: corpo baixo, oito patas e os olhos vermelhos."""
    step = frame
    legs: list[Box] = []
    for y, direction in ((4, 1), (7, -1), (10, 1), (13, -1)):
        legs.append((SPIDER_LEG, 0, y + direction * step, 5, 1))
        legs.append((SPIDER_LEG, 11, y + direction * step, 5, 1))
    return _voxel(
        [
            *legs,  # antes do corpo: as patas saem de tras dele
            (SPIDER_BODY, 4, 5, 8, 6),
            (SPIDER_BODY, 5, 2, 6, 3),
            (SPIDER_EYE, 5, 3, 2, 1),
            (SPIDER_EYE, 9, 3, 2, 1),
        ]
    )

make_stone(seed=3)

Textura de pedra, 16x16.

Source code in src/textures.py
41
42
43
def make_stone(seed: int = 3) -> pygame.Surface:
    """Textura de pedra, 16x16."""
    return _shaded((128, 128, 128), seed)

make_villager(frame=0)

Aldeao: tunica marrom, bracos cruzados e o nariz que e a marca da especie.

Source code in src/textures.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def make_villager(frame: int = 0) -> pygame.Surface:
    """Aldeao: tunica marrom, bracos cruzados e o nariz que e a marca da especie."""
    arms = frame
    return _voxel(
        [
            (VILLAGER_SKIN, 5, 1, 6, 5),
            (VILLAGER_HAIR, 5, 0, 6, 2),
            ((40, 32, 28), 6, 3, 1, 1),
            ((40, 32, 28), 9, 3, 1, 1),
            (VILLAGER_NOSE, 7, 3, 2, 3),
            (VILLAGER_ROBE, 4, 6, 8, 10),
            (VILLAGER_APRON, 4, 9 + arms, 8, 2),  # bracos cruzados sobem e descem
        ]
    )

make_witch(frame=0)

Bruxa: chapeu de aba larga, manto roxo e o frasco de pocao na mao.

Source code in src/textures.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def make_witch(frame: int = 0) -> pygame.Surface:
    """Bruxa: chapeu de aba larga, manto roxo e o frasco de pocao na mao."""
    lift = frame  # o frasco sobe e desce
    return _voxel(
        [
            (WITCH_HAT, 6, 0, 4, 3),
            (WITCH_HAT, 3, 3, 10, 1),  # aba
            (WITCH_SKIN, 5, 4, 6, 4),
            (WITCH_HAT, 5, 4, 6, 1),  # franja
            ((40, 30, 30), 6, 6, 1, 1),
            ((40, 30, 30), 9, 6, 1, 1),
            (WITCH_NOSE, 7, 6, 2, 2),
            (WITCH_ROBE, 4, 8, 8, 8),
            (WITCH_HAT, 4, 15, 8, 1),
            (WITCH_SKIN, 3, 9, 1, 3),
            (WITCH_SKIN, 12, 9, 1, 3),
            (WITCH_POTION, 12, 11 - lift, 2, 2),
        ]
    )

scale_pixel_perfect(surface, size)

Escala uma textura quadrada para size x size sem suavizar (vizinho-mais-proximo).

Source code in src/textures.py
346
347
348
def scale_pixel_perfect(surface: pygame.Surface, size: int) -> pygame.Surface:
    """Escala uma textura quadrada para `size x size` sem suavizar (vizinho-mais-proximo)."""
    return pygame.transform.scale(surface, (size, size))

src.ui

src.ui

HUD e telas de estado, com fonte pixelada e sombra dura (R7.5, R7.6).

HUD_SCORE_MARGIN = 40 module-attribute

Distancia do topo da area jogavel ate o centro da pontuacao, quando nao ha faixa de ceu para receber o HUD. E a posicao da v1/v2.

LAST_SCORE_COLOR = (235, 235, 235) module-attribute

Cor da linha de ultima pontuacao: branco quase puro, para bom contraste contra o fundo, mas ainda visualmente subordinada ao dourado GOLD do recorde (R36.1).

draw_game_over_screen(renderer, score, highscore)

Desenha a tela de GAME_OVER: placar, recorde e instrucao de reinicio.

Source code in src/ui.py
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
def draw_game_over_screen(renderer: render.Renderer, score: int, highscore: int) -> None:
    """Desenha a tela de GAME_OVER: placar, recorde e instrucao de reinicio."""
    play = config.play()
    _dim_overlay(renderer)
    _stack(
        renderer,
        play.centerx,
        play.centery - 90,
        [
            ("GAME OVER", 12, (220, 60, 50)),
            (f"PONTOS: {score}", 8, (255, 255, 255)),
            (f"RECORDE: {highscore}", 8, (255, 255, 255)),
            ("ESPACO / CLIQUE PARA REINICIAR", 5, (220, 220, 220)),
        ],
    )

draw_hud_score(renderer, score)

Desenha o placar do HUD, sobre o ceu ou logo abaixo dele conforme o espaco.

Source code in src/ui.py
262
263
264
def draw_hud_score(renderer: render.Renderer, score: int) -> None:
    """Desenha o placar do HUD, sobre o ceu ou logo abaixo dele conforme o espaco."""
    draw_text(renderer, str(score), hud_score_center(), base_size=12)

draw_mute_icon(renderer, muted)

Botao de mudo tocavel no canto da tela, estilo voxel (R15.4).

Source code in src/ui.py
241
242
243
244
def draw_mute_icon(renderer: render.Renderer, muted: bool) -> None:
    """Botao de mudo tocavel no canto da tela, estilo voxel (R15.4)."""
    image = renderer.image(("mute_icon", muted), lambda: _paint_mute_icon(muted))
    renderer.draw(image, mute_icon_rect().topleft)

draw_paused_overlay(renderer)

Desenha o overlay de PAUSADO sobre o jogo, com a dica de mudo por seta.

Source code in src/ui.py
267
268
269
270
271
272
273
274
275
276
277
278
279
def draw_paused_overlay(renderer: render.Renderer) -> None:
    """Desenha o overlay de PAUSADO sobre o jogo, com a dica de mudo por seta."""
    play = config.play()
    _dim_overlay(renderer)
    _stack(
        renderer,
        play.centerx,
        play.centery - 24,
        [
            ("PAUSADO", 12, (255, 255, 255)),
            ("SETAS: MUDO", 5, (210, 210, 210)),
        ],
    )

draw_ready_screen(renderer, highscore, last_score)

Desenha a tela PRONTO: titulo, creditos, instrucao de voo, recorde e, se houver.

A pontuacao da partida anterior nesta execucao (R36).

Source code in src/ui.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def draw_ready_screen(renderer: render.Renderer, highscore: int, last_score: int | None) -> None:
    """Desenha a tela PRONTO: titulo, creditos, instrucao de voo, recorde e, se houver.

    A pontuacao da partida anterior nesta execucao (R36).
    """
    play = config.play()
    next_y = _stack(
        renderer,
        play.centerx,
        play.top + play.height // 4,
        [
            ("BLOCKY BEE", 12, (255, 220, 60)),
            (CREDITS.upper(), 5, (230, 230, 230)),
        ],
    )
    instruction = "ESPACO / CLIQUE PARA VOAR"
    instruction_scale = _fit_scale(instruction, _scale_for(9))
    instruction_y = next_y + 16 + (pixelfont.GLYPH_H * instruction_scale) // 2
    draw_text(
        renderer,
        instruction,
        (play.centerx, instruction_y),
        base_size=9,
        color=GOLD,
    )
    record_y = config.ground_y() - 24
    draw_text(
        renderer,
        f"RECORDE: {highscore}",
        (play.centerx, record_y),
        base_size=8,
        color=GOLD,
    )
    if last_score is not None:
        record_scale = _fit_scale(f"RECORDE: {highscore}", _scale_for(8))
        record_top = record_y - (pixelfont.GLYPH_H * record_scale) // 2
        last_score_text = f"ANTERIOR: {last_score}"
        last_score_scale = _fit_scale(last_score_text, _scale_for(6))
        last_score_y = record_top - 14 - (pixelfont.GLYPH_H * last_score_scale) // 2
        draw_text(
            renderer,
            last_score_text,
            (play.centerx, last_score_y),
            base_size=6,
            color=LAST_SCORE_COLOR,
        )

draw_text(renderer, text, center, base_size=12, color=(255, 255, 255))

Desenha text centrado em center, com sombra dura e escala ajustada a largura.

Source code in src/ui.py
106
107
108
109
110
111
112
113
114
115
116
117
118
def draw_text(
    renderer: render.Renderer,
    text: str,
    center: tuple[int, int],
    base_size: int = 12,
    color: tuple[int, int, int] = (255, 255, 255),
) -> None:
    """Desenha `text` centrado em `center`, com sombra dura e escala ajustada a largura."""
    scale = _fit_scale(text, _scale_for(base_size))
    shadow = _text_image(renderer, text, scale, SHADOW_COLOR)
    main = _text_image(renderer, text, scale, color)
    renderer.draw(shadow, _centered(shadow, center[0] + SHADOW_OFFSET, center[1] + SHADOW_OFFSET))
    renderer.draw(main, _centered(main, *center))

hud_score_center(base_size=12)

Onde a pontuacao do HUD e desenhada.

Quando existe faixa de ceu e ela comporta o texto, a pontuacao vai para o meio dela, liberando a area de jogo (R25.7). Numa tela 2:3, sem faixa, cai exatamente onde caia na v2.

Source code in src/ui.py
247
248
249
250
251
252
253
254
255
256
257
258
259
def hud_score_center(base_size: int = 12) -> tuple[int, int]:
    """Onde a pontuacao do HUD e desenhada.

    Quando existe faixa de ceu e ela comporta o texto, a pontuacao vai para o meio
    dela, liberando a area de jogo (R25.7). Numa tela 2:3, sem faixa, cai exatamente
    onde caia na v2.
    """
    vp = config.viewport()
    sky = vp.sky_band
    text_h = pixelfont.GLYPH_H * _scale_for(base_size) + SHADOW_OFFSET
    if sky.height >= text_h:
        return vp.play.centerx, sky.centery
    return vp.play.centerx, vp.play.top + HUD_SCORE_MARGIN

max_text_w()

Largura maxima de um texto: a area jogavel menos 20px de margem de cada lado.

Medida contra a area jogavel, e nao contra o canvas, para que o texto nunca escorra por cima das faixas laterais numa tela larga (R25.2).

Source code in src/ui.py
22
23
24
25
26
27
28
def max_text_w() -> int:
    """Largura maxima de um texto: a area jogavel menos 20px de margem de cada lado.

    Medida contra a area jogavel, e nao contra o canvas, para que o texto nunca
    escorra por cima das faixas laterais numa tela larga (R25.2).
    """
    return config.play().width - 40

mute_icon_rect()

Geometria do botao de mudo, no canto superior direito do canvas (R15.4).

Fica no canto da tela de verdade, e nao no da area jogavel: e o ponto mais alcancavel no celular, e uma faixa decorativa pode receber controle de interface (R25.6). Verticalmente ele mora na faixa de ceu quando ela o comporta (R25.7); se a faixa for curta demais, desce para dentro da area jogavel em vez de ficar metade em cada uma.

Funcao, e nao constante, porque o canvas so tem dimensao depois que o display existe. input.py importa daqui para o hit-test, mantendo desenho e posicao do botao como uma unica fonte de verdade.

Source code in src/ui.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def mute_icon_rect() -> pygame.Rect:
    """Geometria do botao de mudo, no canto superior direito do canvas (R15.4).

    Fica no canto da tela de verdade, e nao no da area jogavel: e o ponto mais
    alcancavel no celular, e uma faixa decorativa pode receber controle de interface
    (R25.6). Verticalmente ele mora na faixa de ceu quando ela o comporta (R25.7);
    se a faixa for curta demais, desce para dentro da area jogavel em vez de ficar
    metade em cada uma.

    Funcao, e nao constante, porque o canvas so tem dimensao depois que o display
    existe. `input.py` importa daqui para o hit-test, mantendo desenho e posicao do
    botao como uma unica fonte de verdade.
    """
    vp = config.viewport()
    needed = MUTE_ICON_MARGIN + MUTE_ICON_SIZE
    top = MUTE_ICON_MARGIN if vp.sky_band.height >= needed else vp.play.top + MUTE_ICON_MARGIN
    return pygame.Rect(
        vp.width - MUTE_ICON_MARGIN - MUTE_ICON_SIZE,
        top,
        MUTE_ICON_SIZE,
        MUTE_ICON_SIZE,
    )

src.viewport

src.viewport

Canvas logico e area jogavel: como o jogo preenche telas de qualquer proporcao (R23, R24).

Duas grandezas passam a ser distintas, e essa distincao e o coracao da v3:

  • Area jogavel — sempre 480x720 de mundo. E onde a abelha voa, onde as colunas existem e onde a colisao acontece. Nenhuma constante de fisica ou de bioma depende da tela, entao o desafio e identico no celular e no PC e os recordes continuam comparaveis (R24.1, R24.2).
  • Canvas logico — tem a proporcao real da tela (Android) ou da janela (desktop). Contem a area jogavel e, ao redor dela, as faixas decorativas (R25).

Por construcao so um dos dois eixos sobra: o canvas recebe exatamente a proporcao da tela, entao a escala final e uniforme e preenche tudo — nenhuma barra preta e nenhum corte (R23.1, R23.2, R23.4).

Ver specs/v3/design.md secao 32.

DESKTOP_WINDOW = (960, 720) module-attribute

Janela padrao do desktop: mais larga que a area jogavel, para as faixas laterais aparecerem sem o jogador precisar redimensionar nada (R23.7) e para a conferencia visual no PC ser representativa do celular.

ENV_CANVAS = 'BLOCKY_CANVAS' module-attribute

BLOCKY_CANVAS=LxA forca o canvas, para conferir a proporcao de um celular sem aparelho. Ferramenta de desenvolvimento, no mesmo espirito de BLOCKY_PERF.

ENV_ORIENTATION = 'SDL_IOS_ORIENTATIONS' module-attribute

Nome de ambiente do hint SDL_HINT_ORIENTATIONS do SDL2.

O prefixo IOS e historico: a documentacao do proprio SDL descreve o hint como "which orientations are allowed on iOS/Android". Escrever SDL_HINT_ORIENTATIONS no ambiente nao teria efeito nenhum — esse e o nome da macro em C, e SDL_GetHint procura pela string para a qual ela aponta, que e esta.

MAX_SKY_EXTRA = 2 * BLOCK module-attribute

Teto do que a sobra vertical pode virar de ceu: ate 2 fileiras de bloco.

O limite existe para que uma tela muito alongada nao vire um ceu gigante — gradiente do tamanho do canvas, parallax e um campo inteiro de mobs espalhados por uma faixa que pode passar de 250px de altura num celular alto, exatamente o tipo de decoracao mais caro de desenhar. O que passa disso vai para o chao, que e uma faixa tileavel sem gradiente nem parallax e por isso absorve altura sem custar mais. O valor foi escolhido por inspecao visual, nao por analise.

PLAY_H = 720 module-attribute

Area jogavel em coordenadas de mundo. Imutavel: e a calibracao de dificuldade da v1 (task 12), que a v3 se compromete a nao mexer (R24.1).

Viewport dataclass

Canvas logico com a area jogavel posicionada dentro dele.

As faixas sao derivadas, nao armazenadas: sky_band e ground_band cobrem a largura toda do canvas e left_band/right_band a altura toda, porque ceu, parallax e chao sao desenhados de borda a borda e as faixas laterais os cobrem depois (R25.5, design secao 32.6).

Source code in src/viewport.py
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
@dataclass(frozen=True)
class Viewport:
    """Canvas logico com a area jogavel posicionada dentro dele.

    As faixas sao derivadas, nao armazenadas: `sky_band` e `ground_band` cobrem a
    largura toda do canvas e `left_band`/`right_band` a altura toda, porque ceu,
    parallax e chao sao desenhados de borda a borda e as faixas laterais os cobrem
    depois (R25.5, design secao 32.6).
    """

    canvas: tuple[int, int]
    play: pygame.Rect
    sky_extra: int
    ground_extra: int

    @property
    def width(self) -> int:
        """Largura do canvas logico."""
        return self.canvas[0]

    @property
    def height(self) -> int:
        """Altura do canvas logico."""
        return self.canvas[1]

    @property
    def sky_band(self) -> pygame.Rect:
        """Faixa de ceu estendido acima da area jogavel (R25.1). Vazia numa tela 2:3."""
        return pygame.Rect(0, 0, self.width, self.sky_extra)

    @property
    def ground_band(self) -> pygame.Rect:
        """Fileiras extras de chao abaixo da area jogavel (R25.1). Vazia numa tela 2:3."""
        return pygame.Rect(0, self.play.bottom, self.width, self.ground_extra)

    @property
    def left_band(self) -> pygame.Rect:
        """Corte transversal do subsolo a esquerda (R25.2). Vazia numa tela 2:3."""
        return pygame.Rect(0, 0, self.play.left, self.height)

    @property
    def right_band(self) -> pygame.Rect:
        """Corte transversal do subsolo a direita (R25.2). Vazia numa tela 2:3.

        Calculada a partir de `play.right`, e nao como espelho de `left_band`, para
        absorver o pixel impar quando `canvas_w - PLAY_W` e impar.
        """
        return pygame.Rect(self.play.right, 0, self.width - self.play.right, self.height)
ground_band property

Fileiras extras de chao abaixo da area jogavel (R25.1). Vazia numa tela 2:3.

height property

Altura do canvas logico.

left_band property

Corte transversal do subsolo a esquerda (R25.2). Vazia numa tela 2:3.

right_band property

Corte transversal do subsolo a direita (R25.2). Vazia numa tela 2:3.

Calculada a partir de play.right, e nao como espelho de left_band, para absorver o pixel impar quando canvas_w - PLAY_W e impar.

sky_band property

Faixa de ceu estendido acima da area jogavel (R25.1). Vazia numa tela 2:3.

width property

Largura do canvas logico.

compute(screen_w, screen_h)

Deriva o canvas logico e a area jogavel de um tamanho real de tela ou janela.

O canvas recebe a proporcao da tela e cresce a partir da area jogavel, nunca encolhendo abaixo dela. A sobra vertical vira ceu (ate MAX_SKY_EXTRA) e o resto vira chao; a sobra horizontal vira faixa lateral, com a area jogavel centralizada. Tamanho invalido devolve o canvas 2:3, na mesma disciplina de scale.fit_scale — nunca deveria ocorrer com uma tela real, e um numero estranho jamais deve impedir o jogo de abrir.

Source code in src/viewport.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
def compute(screen_w: int, screen_h: int) -> Viewport:
    """Deriva o canvas logico e a area jogavel de um tamanho real de tela ou janela.

    O canvas recebe a proporcao da tela e cresce a partir da area jogavel, nunca
    encolhendo abaixo dela. A sobra vertical vira ceu (ate `MAX_SKY_EXTRA`) e o
    resto vira chao; a sobra horizontal vira faixa lateral, com a area jogavel
    centralizada. Tamanho invalido devolve o canvas 2:3, na mesma disciplina de
    `scale.fit_scale` — nunca deveria ocorrer com uma tela real, e um numero
    estranho jamais deve impedir o jogo de abrir.
    """
    if screen_w <= 0 or screen_h <= 0:
        screen_w, screen_h = PLAY_W, PLAY_H

    if screen_w * PLAY_H < screen_h * PLAY_W:  # tela mais alongada que 2:3 (celular em retrato)
        canvas_w = PLAY_W
        canvas_h = round(PLAY_W * screen_h / screen_w)
    else:  # tela mais larga que 2:3 (monitor, janela padrao do desktop)
        canvas_h = PLAY_H
        canvas_w = round(PLAY_H * screen_w / screen_h)
    canvas_w = max(canvas_w, PLAY_W)
    canvas_h = max(canvas_h, PLAY_H)

    leftover_v = canvas_h - PLAY_H
    sky_extra = min(leftover_v, MAX_SKY_EXTRA)
    ground_extra = leftover_v - sky_extra

    play = pygame.Rect((canvas_w - PLAY_W) // 2, sky_extra, PLAY_W, PLAY_H)
    return Viewport(canvas=(canvas_w, canvas_h), play=play, sky_extra=sky_extra, ground_extra=ground_extra)

forced_size()

Le BLOCKY_CANVAS=LxA, ou None se ausente ou mal formada.

Como compute e idempotente sobre a propria saida, forcar o tamanho de tela equivale a forcar o canvas. Valor invalido e ignorado em silencio: e ferramenta de desenvolvimento e nunca deve impedir o jogo de abrir.

Source code in src/viewport.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
def forced_size() -> tuple[int, int] | None:
    """Le `BLOCKY_CANVAS=LxA`, ou None se ausente ou mal formada.

    Como `compute` e idempotente sobre a propria saida, forcar o tamanho de tela
    equivale a forcar o canvas. Valor invalido e ignorado em silencio: e ferramenta
    de desenvolvimento e nunca deve impedir o jogo de abrir.
    """
    raw = os.environ.get(ENV_CANVAS, "").lower()
    parts = raw.split("x")
    if len(parts) != 2:
        return None
    try:
        width, height = int(parts[0]), int(parts[1])
    except ValueError:
        return None
    if width <= 0 or height <= 0:
        return None
    return width, height

lock_portrait_orientation()

Trava a orientacao em retrato pelo lado do SDL. Chamar ANTES de pygame.init().

Reforca o orientation = portrait que o buildozer.spec ja declara: o manifesto convence o Android, este hint convence o SDL, e o jogo nunca gira quando o aparelho e virado (R23.5). Nao sobrescreve um valor ja definido no ambiente, para continuar sendo possivel investigar paisagem sem editar codigo.

Source code in src/viewport.py
180
181
182
183
184
185
186
187
188
def lock_portrait_orientation() -> None:
    """Trava a orientacao em retrato pelo lado do SDL. Chamar ANTES de `pygame.init()`.

    Reforca o `orientation = portrait` que o `buildozer.spec` ja declara: o manifesto
    convence o Android, este hint convence o SDL, e o jogo nunca gira quando o
    aparelho e virado (R23.5). Nao sobrescreve um valor ja definido no ambiente, para
    continuar sendo possivel investigar paisagem sem editar codigo.
    """
    os.environ.setdefault(ENV_ORIENTATION, "Portrait")

screen_size()

Tamanho real de tela/janela que alimenta compute.

No Android e a resolucao nativa do aparelho, que e o que faz a tela cheia preencher os quatro lados (R23.3); no desktop e a janela padrao mais larga que a area jogavel (R23.7). BLOCKY_CANVAS tem precedencia sobre os dois.

Source code in src/viewport.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def screen_size() -> tuple[int, int]:
    """Tamanho real de tela/janela que alimenta `compute`.

    No Android e a resolucao nativa do aparelho, que e o que faz a tela cheia
    preencher os quatro lados (R23.3); no desktop e a janela padrao mais larga que a
    area jogavel (R23.7). `BLOCKY_CANVAS` tem precedencia sobre os dois.
    """
    forced = forced_size()
    if forced is not None:
        return forced
    if is_android():
        try:
            sizes = pygame.display.get_desktop_sizes()
        except pygame.error:  # display ainda nao inicializado
            sizes = []
        if sizes:
            return sizes[0]
    return DESKTOP_WINDOW