slacker-game/slacker_game/__init__.py

260 lines
9.6 KiB
Python
Raw Permalink Normal View History

2020-09-05 11:09:52 +02:00
# A clone of the arcade game Stacker
# Copyright (C) 2007 Clint Herron
2020-09-05 11:33:01 +02:00
# Copyright (C) 2017-2020 Nguyễn Gia Phong
2020-09-05 11:09:52 +02:00
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""A clone of the arcade game Stacker"""
2020-09-06 17:02:12 +02:00
from __future__ import annotations
2020-09-05 11:09:52 +02:00
2020-09-06 09:24:37 +02:00
from contextlib import ExitStack, redirect_stdout
from importlib.resources import path
from io import StringIO
2020-09-05 11:09:52 +02:00
from math import cos, pi
from random import randrange
2020-09-06 10:51:54 +02:00
from typing import List, Optional
2020-09-05 11:09:52 +02:00
2020-09-06 09:24:37 +02:00
with redirect_stdout(StringIO()): import pygame
2020-09-06 10:01:20 +02:00
from pygame import (K_0, K_1, K_9, K_ESCAPE, K_SPACE, KEYDOWN,
QUIT, K_q, Rect, draw, event, image)
from pygame.display import flip, set_caption, set_icon, set_mode
from pygame.font import Font
2020-09-06 10:51:54 +02:00
from pygame.surface import Surface
2020-09-06 10:01:20 +02:00
from pygame.time import get_ticks
2020-09-05 11:09:52 +02:00
2020-09-06 17:02:12 +02:00
__version__ = '2.1.1'
2020-09-05 11:09:52 +02:00
TANGO = {'Butter': ((252, 233, 79), (237, 212, 0), (196, 160, 0)),
'Orange': ((252, 175, 62), (245, 121, 0), (206, 92, 0)),
'Chocolate': ((233, 185, 110), (193, 125, 17), (143, 89, 2)),
'Chameleon': ((138, 226, 52), (115, 210, 22), (78, 154, 6)),
'Sky Blue': ((114, 159, 207), (52, 101, 164), (32, 74, 135)),
'Plum': ((173, 127, 168), (117, 80, 123), (92, 53, 102)),
'Scarlet Red': ((239, 41, 41), (204, 0, 0), (164, 0, 0)),
'Aluminium': ((238, 238, 236), (211, 215, 207), (186, 189, 182),
(136, 138, 133), (85, 87, 83), (46, 52, 54))}
2020-09-06 09:24:37 +02:00
BG_COLOR = TANGO['Aluminium'][5]
COLOR_MAJOR = TANGO['Scarlet Red']
COLOR_MINOR = TANGO['Sky Blue']
MAJOR = 5
2020-09-05 11:09:52 +02:00
2020-09-06 09:24:37 +02:00
INTRO, PLAYING, LOSE, WIN = range(4)
MAX_WIDTH = (1,)*7 + (2,)*5 + (3,)*3
WIN_LEVEL = 15
SCREEN_SIZE = SCREEN_WIDTH, SCREEN_HEIGHT = 280, 600
BOARD_SIZE = BOARD_WIDTH, BOARD_HEIGHT = 7, 15
TILE_SIZE = 40
MAX_SPEED, WIN_SPEED, SPEED_DIFF = 70, 100, 5
INIT_SPEED = MAX_SPEED + (BOARD_HEIGHT + 1)*SPEED_DIFF
2020-09-05 11:09:52 +02:00
class SlackerTile:
"""SlackerTile(x, y) -> SlackerTile
Slacker object for storing tiles.
"""
2020-09-06 10:51:54 +02:00
def __init__(self, screen: Surface,
x: float, y: float, state: int = PLAYING,
missed_time: Optional[int] = None) -> None:
2020-09-05 11:09:52 +02:00
self.screen, self.x, self.y = screen, x, y
2020-09-06 09:24:37 +02:00
if state == LOSE:
2020-09-05 11:09:52 +02:00
self.dim = 1
elif missed_time is None:
self.dim = 0
else:
self.dim = 2
self.missed_time = missed_time
2020-09-06 09:24:37 +02:00
self.wiggle = state in (INTRO, WIN)
2020-09-05 11:09:52 +02:00
2020-09-06 10:51:54 +02:00
def get_xoffset(self, maxoffset: float, duration: int = 820) -> float:
2020-09-06 17:02:12 +02:00
"""Return the offset on x-axis for wiggling oscillation.
The oscillation's cycle can be specified
in milliseconds as duration.
2020-09-05 11:09:52 +02:00
"""
2020-09-06 10:01:20 +02:00
if not self.wiggle: return 0
return maxoffset * cos((get_ticks()/duration+self.y/BOARD_HEIGHT)*pi)
2020-09-05 11:09:52 +02:00
2020-09-06 10:51:54 +02:00
def get_yoffset(self) -> float:
2020-09-05 11:09:52 +02:00
"""Return the offset on y-axis when the tile is falling."""
2020-09-06 10:01:20 +02:00
if self.missed_time is None: return 0
2020-09-06 10:51:54 +02:00
return (get_ticks()-self.missed_time)**2 / 25000
2020-09-05 11:09:52 +02:00
2020-09-06 10:51:54 +02:00
def isfallen(self) -> bool:
2020-09-05 11:09:52 +02:00
"""Return if the tile has fallen off the screen."""
2020-09-06 09:24:37 +02:00
return self.y + self.get_yoffset() > BOARD_HEIGHT
2020-09-05 11:09:52 +02:00
2020-09-06 10:51:54 +02:00
def draw(self, max_x_offset: float = 2.0) -> None:
2020-09-05 11:09:52 +02:00
"""Draw the tile."""
2020-09-06 09:24:37 +02:00
if self.y < MAJOR:
color = COLOR_MAJOR
2020-09-05 11:09:52 +02:00
else:
2020-09-06 09:24:37 +02:00
color = COLOR_MINOR
2020-09-06 10:01:20 +02:00
rect = Rect((self.x+self.get_xoffset(max_x_offset))*TILE_SIZE,
(self.y+self.get_yoffset())*TILE_SIZE,
TILE_SIZE, TILE_SIZE)
draw.rect(self.screen, color[self.dim], rect)
2020-09-06 10:51:54 +02:00
draw.rect(self.screen, BG_COLOR, rect, TILE_SIZE//11)
2020-09-05 11:09:52 +02:00
class Slacker:
2020-09-06 17:02:12 +02:00
"""Game object."""
2020-09-05 11:09:52 +02:00
2020-09-06 10:51:54 +02:00
def __init__(self, restart: bool = False) -> None:
2020-09-06 09:24:37 +02:00
self.exit_stack = ExitStack()
self.font = self.data('VT323-Regular.ttf')
2020-09-06 10:51:54 +02:00
self.board = [[False]*BOARD_WIDTH for h in range(BOARD_HEIGHT)]
2020-09-06 09:24:37 +02:00
self.game_state = PLAYING if restart else INTRO
2020-09-06 10:51:54 +02:00
self.falling_tiles: List[SlackerTile] = []
2020-09-06 09:24:37 +02:00
self.speed = INIT_SPEED + randrange(5)
2020-09-05 11:09:52 +02:00
self.speed_ratio = 1.0
2020-09-06 09:24:37 +02:00
self.width = MAX_WIDTH[-1]
self.y = BOARD_HEIGHT - 1
2020-09-06 10:51:54 +02:00
def __enter__(self) -> Slacker:
2020-09-06 09:24:37 +02:00
pygame.init()
2020-09-06 10:01:20 +02:00
set_caption('Slacker')
set_icon(image.load(self.data('icon.png')))
self.screen = set_mode(SCREEN_SIZE)
2020-09-06 09:24:37 +02:00
return self
2020-09-06 10:51:54 +02:00
def __exit__(self, *exc) -> None:
2020-09-06 09:24:37 +02:00
pygame.quit()
self.exit_stack.close()
2020-09-06 10:51:54 +02:00
def data(self, resource: str) -> str:
2020-09-06 09:24:37 +02:00
"""Return a true filesystem path for specified resource."""
return str(self.exit_stack.enter_context(
path('slacker_game', resource)))
2020-09-05 11:09:52 +02:00
2020-09-06 10:51:54 +02:00
def draw_text(self, string: str, height: float):
2020-09-05 11:09:52 +02:00
"""Width-fit the string in the screen on the given height."""
2020-09-06 10:01:20 +02:00
font = Font(self.font, int(SCREEN_WIDTH*2.5/(len(string)+1)))
2020-09-06 09:24:37 +02:00
text = font.render(string, False, COLOR_MINOR[0])
self.screen.blit(text, ((SCREEN_WIDTH - text.get_width()) // 2,
int(SCREEN_HEIGHT * height)))
2020-09-05 11:09:52 +02:00
2020-09-06 10:51:54 +02:00
def intro(self) -> None:
2020-09-05 11:09:52 +02:00
"""Draw the intro screen."""
for i in [(2, 2), (3, 2), (4, 2), (1.5, 3), (4.5, 3),
(1.5, 4), (2, 5), (3, 5), (4, 5), (4.5, 6),
(1.5, 7), (4.5, 7), (2, 8), (3, 8), (4, 8)]:
2020-09-06 09:24:37 +02:00
SlackerTile(self.screen, *i, state=INTRO).draw(1.5)
2020-09-06 10:01:20 +02:00
if get_ticks() // 820 % 2:
2020-09-05 11:09:52 +02:00
self.draw_text('Press Spacebar', 0.75)
2020-09-06 10:51:54 +02:00
def draw_board(self) -> None:
2020-09-05 11:09:52 +02:00
"""Draw the board and the tiles inside."""
for y, row in enumerate(self.board):
for x, block in enumerate(row):
if block:
SlackerTile(self.screen, x, y, self.game_state).draw()
# Draw the falling tiles
for ft in self.falling_tiles:
if ft.isfallen():
self.falling_tiles.remove(ft)
else:
ft.draw()
2020-09-06 10:51:54 +02:00
def update_screen(self) -> None:
2020-09-05 11:09:52 +02:00
"""Draw the whole screen and everything inside."""
2020-09-06 09:24:37 +02:00
self.screen.fill(BG_COLOR)
if self.game_state == INTRO:
2020-09-05 11:09:52 +02:00
self.intro()
2020-09-06 09:24:37 +02:00
elif self.game_state in (PLAYING, LOSE, WIN):
2020-09-05 11:09:52 +02:00
self.draw_board()
2020-09-06 10:01:20 +02:00
flip()
2020-09-05 11:09:52 +02:00
2020-09-06 10:51:54 +02:00
def update_movement(self) -> None:
2020-09-05 11:09:52 +02:00
"""Update the direction the blocks are moving in."""
speed = self.speed * self.speed_ratio
2020-09-06 09:24:37 +02:00
positions = BOARD_WIDTH + self.width - 2
2020-09-06 10:01:20 +02:00
p = int(round(get_ticks()/speed)) % (positions*2)
2020-09-05 11:09:52 +02:00
self.x = (-p % positions if p > positions else p) - self.width + 1
self.board[self.y] = [0 <= x - self.x < self.width
2020-09-06 09:24:37 +02:00
for x in range(BOARD_WIDTH)]
2020-09-05 11:09:52 +02:00
2020-09-06 10:51:54 +02:00
def key_hit(self) -> None:
2020-09-06 17:02:12 +02:00
"""Handle block-stacking event.
Process the current position of the blocks, relative to the ones
underneath when user hit the switch, then decide if the user
will win, lose or go to the next level of the tower.
2020-09-05 11:09:52 +02:00
"""
2020-09-06 09:24:37 +02:00
if self.y < BOARD_HEIGHT - 1:
2020-09-05 11:09:52 +02:00
for x in range(max(0, self.x),
2020-09-06 17:02:12 +02:00
min(self.x+self.width, BOARD_WIDTH)):
if self.board[self.y + 1][x]: continue
# If there isn't any block underneath,
# get rid of the block not standing on solid ground
self.board[self.y][x] = False
# Then, add that falling block to falling_tiles
self.falling_tiles.append(SlackerTile(
self.screen, x, self.y, missed_time=get_ticks()))
2020-09-05 11:09:52 +02:00
self.width = sum(self.board[self.y])
if not self.width:
2020-09-06 09:24:37 +02:00
self.game_state = LOSE
2020-09-05 11:09:52 +02:00
elif not self.y:
2020-09-06 09:24:37 +02:00
self.game_state = WIN
2020-09-05 11:09:52 +02:00
else:
self.y -= 1
2020-09-06 09:24:37 +02:00
self.width = min(self.width, MAX_WIDTH[self.y])
self.speed = MAX_SPEED + self.y*SPEED_DIFF + randrange(5)
2020-09-05 11:09:52 +02:00
2020-09-06 10:51:54 +02:00
def handle_intro(self) -> bool:
2020-09-06 09:40:55 +02:00
"""Handle events in intro."""
2020-09-06 10:01:20 +02:00
for e in event.get():
if e.type == QUIT: return False
if e.type != KEYDOWN: continue
if e.key in (K_ESCAPE, K_q): return False
if e.key == K_SPACE: self.game_state = PLAYING
2020-09-06 09:40:55 +02:00
return True
2020-09-06 10:51:54 +02:00
def handle_playing(self) -> bool:
2020-09-06 09:40:55 +02:00
"""Handle events in game."""
2020-09-06 10:01:20 +02:00
for e in event.get():
if e.type == QUIT: return False
if e.type != KEYDOWN: continue
if e.key == K_SPACE:
2020-09-06 09:40:55 +02:00
self.key_hit()
2020-09-06 10:01:20 +02:00
elif e.key in (K_ESCAPE, K_q):
2020-09-06 10:51:54 +02:00
Slacker.__init__(self)
2020-09-06 09:40:55 +02:00
# Yes, these are cheats.
2020-09-06 10:01:20 +02:00
elif e.key == K_0:
2020-09-06 09:40:55 +02:00
self.width += self.width < BOARD_WIDTH
2020-09-06 10:01:20 +02:00
elif K_1 <= e.key <= K_9 + 1:
self.speed_ratio = (K_9-e.key+1) / 5.0
2020-09-06 09:40:55 +02:00
self.update_movement()
return True
2020-09-06 10:51:54 +02:00
def handle_ending(self) -> bool:
2020-09-06 09:40:55 +02:00
"""Handle events in ending screens."""
2020-09-06 10:01:20 +02:00
for e in event.get():
if e.type == QUIT: return False
2020-09-06 10:51:54 +02:00
if e.type == KEYDOWN: Slacker.__init__(self, restart=True)
2020-09-06 09:40:55 +02:00
return True
2020-09-06 10:51:54 +02:00
def handle_events(self) -> bool:
2020-09-06 09:40:55 +02:00
"""Handle queued events."""
if self.game_state == INTRO: return self.handle_intro()
if self.game_state == PLAYING: return self.handle_playing()
if self.game_state in (LOSE, WIN): return self.handle_ending()
return False