|
|
|
@ -1,4 +1,3 @@
|
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
# A clone of the arcade game Stacker
|
|
|
|
|
# Copyright (C) 2007 Clint Herron
|
|
|
|
|
# Copyright (C) 2017-2020 Nguyễn Gia Phong
|
|
|
|
@ -18,14 +17,23 @@
|
|
|
|
|
|
|
|
|
|
"""A clone of the arcade game Stacker"""
|
|
|
|
|
|
|
|
|
|
__version__ = '2.0.2'
|
|
|
|
|
__version__ = '2.1.0'
|
|
|
|
|
__all__ = ['Slacker']
|
|
|
|
|
|
|
|
|
|
from contextlib import ExitStack, redirect_stdout
|
|
|
|
|
from importlib.resources import path
|
|
|
|
|
from io import StringIO
|
|
|
|
|
from math import cos, pi
|
|
|
|
|
from random import randrange
|
|
|
|
|
from typing import List, Optional
|
|
|
|
|
|
|
|
|
|
import pygame
|
|
|
|
|
from pkg_resources import resource_filename
|
|
|
|
|
with redirect_stdout(StringIO()): import pygame
|
|
|
|
|
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
|
|
|
|
|
from pygame.surface import Surface
|
|
|
|
|
from pygame.time import get_ticks
|
|
|
|
|
|
|
|
|
|
TANGO = {'Butter': ((252, 233, 79), (237, 212, 0), (196, 160, 0)),
|
|
|
|
|
'Orange': ((252, 175, 62), (245, 121, 0), (206, 92, 0)),
|
|
|
|
@ -37,10 +45,21 @@ TANGO = {'Butter': ((252, 233, 79), (237, 212, 0), (196, 160, 0)),
|
|
|
|
|
'Aluminium': ((238, 238, 236), (211, 215, 207), (186, 189, 182),
|
|
|
|
|
(136, 138, 133), (85, 87, 83), (46, 52, 54))}
|
|
|
|
|
|
|
|
|
|
BG_COLOR = TANGO['Aluminium'][5]
|
|
|
|
|
COLOR_MAJOR = TANGO['Scarlet Red']
|
|
|
|
|
COLOR_MINOR = TANGO['Sky Blue']
|
|
|
|
|
MAJOR = 5
|
|
|
|
|
|
|
|
|
|
def data(resource):
|
|
|
|
|
"""Return a true filesystem path for specified resource."""
|
|
|
|
|
return resource_filename('slacker_game', resource)
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SlackerTile:
|
|
|
|
@ -48,108 +67,98 @@ class SlackerTile:
|
|
|
|
|
|
|
|
|
|
Slacker object for storing tiles.
|
|
|
|
|
"""
|
|
|
|
|
SIZE = 40
|
|
|
|
|
BG = TANGO['Aluminium'][5]
|
|
|
|
|
MAJOR = 5
|
|
|
|
|
|
|
|
|
|
def __init__(self, screen, x, y, state=1, missed_time=None):
|
|
|
|
|
def __init__(self, screen: Surface,
|
|
|
|
|
x: float, y: float, state: int = PLAYING,
|
|
|
|
|
missed_time: Optional[int] = None) -> None:
|
|
|
|
|
self.screen, self.x, self.y = screen, x, y
|
|
|
|
|
if state == Slacker.LOSE:
|
|
|
|
|
if state == LOSE:
|
|
|
|
|
self.dim = 1
|
|
|
|
|
elif missed_time is None:
|
|
|
|
|
self.dim = 0
|
|
|
|
|
else:
|
|
|
|
|
self.dim = 2
|
|
|
|
|
self.missed_time = missed_time
|
|
|
|
|
self.wiggle = state in (Slacker.INTRO, Slacker.WIN)
|
|
|
|
|
self.wiggle = state in (INTRO, WIN)
|
|
|
|
|
|
|
|
|
|
def get_xoffset(self, maxoffset, duration=820):
|
|
|
|
|
def get_xoffset(self, maxoffset: float, duration: int = 820) -> float:
|
|
|
|
|
"""Return the offset on x-axis to make the tile complete an cycle of
|
|
|
|
|
wiggling oscillation in given duration (in milliseconds).
|
|
|
|
|
"""
|
|
|
|
|
if self.wiggle:
|
|
|
|
|
return maxoffset * cos((pygame.time.get_ticks()/float(duration)
|
|
|
|
|
+ self.y/float(Slacker.BOARD_HEIGHT)) * pi)
|
|
|
|
|
return 0
|
|
|
|
|
if not self.wiggle: return 0
|
|
|
|
|
return maxoffset * cos((get_ticks()/duration+self.y/BOARD_HEIGHT)*pi)
|
|
|
|
|
|
|
|
|
|
def get_yoffset(self):
|
|
|
|
|
def get_yoffset(self) -> float:
|
|
|
|
|
"""Return the offset on y-axis when the tile is falling."""
|
|
|
|
|
if self.missed_time is None:
|
|
|
|
|
return 0
|
|
|
|
|
return (pygame.time.get_ticks() - self.missed_time)**2 / 25000.0
|
|
|
|
|
if self.missed_time is None: return 0
|
|
|
|
|
return (get_ticks()-self.missed_time)**2 / 25000
|
|
|
|
|
|
|
|
|
|
def isfallen(self):
|
|
|
|
|
def isfallen(self) -> bool:
|
|
|
|
|
"""Return if the tile has fallen off the screen."""
|
|
|
|
|
return self.y + self.get_yoffset() > Slacker.BOARD_HEIGHT
|
|
|
|
|
return self.y + self.get_yoffset() > BOARD_HEIGHT
|
|
|
|
|
|
|
|
|
|
def draw(self, max_x_offset=2):
|
|
|
|
|
def draw(self, max_x_offset: float = 2.0) -> None:
|
|
|
|
|
"""Draw the tile."""
|
|
|
|
|
if self.y < self.MAJOR:
|
|
|
|
|
color = Slacker.COLOR_MAJOR
|
|
|
|
|
if self.y < MAJOR:
|
|
|
|
|
color = COLOR_MAJOR
|
|
|
|
|
else:
|
|
|
|
|
color = Slacker.COLOR_MINOR
|
|
|
|
|
rect = pygame.Rect((self.x+self.get_xoffset(max_x_offset)) * self.SIZE,
|
|
|
|
|
(self.y+self.get_yoffset()) * self.SIZE,
|
|
|
|
|
self.SIZE, self.SIZE)
|
|
|
|
|
pygame.draw.rect(self.screen, color[self.dim], rect)
|
|
|
|
|
pygame.draw.rect(self.screen, self.BG, rect, self.SIZE // 11)
|
|
|
|
|
color = COLOR_MINOR
|
|
|
|
|
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)
|
|
|
|
|
draw.rect(self.screen, BG_COLOR, rect, TILE_SIZE//11)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Slacker:
|
|
|
|
|
"""This class provides functions to run the game Slacker, a clone of
|
|
|
|
|
the popular arcade game Stacker.
|
|
|
|
|
"""
|
|
|
|
|
BOARD_SIZE = BOARD_WIDTH, BOARD_HEIGHT = 7, 15
|
|
|
|
|
SCREEN_SIZE = SCREEN_WIDTH, SCREEN_HEIGHT = 280, 600
|
|
|
|
|
TILE_SIZE = 40
|
|
|
|
|
|
|
|
|
|
COLOR_MAJOR = TANGO['Scarlet Red']
|
|
|
|
|
COLOR_MINOR = TANGO['Sky Blue']
|
|
|
|
|
BG_COLOR = TANGO['Aluminium'][5]
|
|
|
|
|
ICON = pygame.image.load(data('icon.png'))
|
|
|
|
|
|
|
|
|
|
MAX_WIDTH = (1,)*7 + (2,)*5 + (3,)*3
|
|
|
|
|
MAX_SPEED = 70
|
|
|
|
|
SPEED_DIFF = 5
|
|
|
|
|
INIT_SPEED = MAX_SPEED + (BOARD_HEIGHT + 1)*SPEED_DIFF
|
|
|
|
|
COLOR_CHANGE_Y = 5
|
|
|
|
|
WIN_LEVEL = 15
|
|
|
|
|
WIN_SPEED = 100
|
|
|
|
|
INTRO, PLAYING, LOSE, WIN = range(4)
|
|
|
|
|
|
|
|
|
|
def __init__(self, restart=False):
|
|
|
|
|
pygame.init()
|
|
|
|
|
pygame.display.set_icon(self.ICON)
|
|
|
|
|
pygame.display.set_caption('Slacker')
|
|
|
|
|
self.board = [[False]*self.BOARD_WIDTH
|
|
|
|
|
for _ in range(self.BOARD_HEIGHT)]
|
|
|
|
|
self.game_state = self.PLAYING if restart else self.INTRO
|
|
|
|
|
self.falling_tiles = []
|
|
|
|
|
self.screen = pygame.display.set_mode(self.SCREEN_SIZE)
|
|
|
|
|
self.speed = self.INIT_SPEED + randrange(5)
|
|
|
|
|
def __init__(self, restart: bool = False) -> None:
|
|
|
|
|
self.exit_stack = ExitStack()
|
|
|
|
|
self.font = self.data('VT323-Regular.ttf')
|
|
|
|
|
self.board = [[False]*BOARD_WIDTH for h in range(BOARD_HEIGHT)]
|
|
|
|
|
self.game_state = PLAYING if restart else INTRO
|
|
|
|
|
self.falling_tiles: List[SlackerTile] = []
|
|
|
|
|
self.speed = INIT_SPEED + randrange(5)
|
|
|
|
|
self.speed_ratio = 1.0
|
|
|
|
|
self.width = self.MAX_WIDTH[-1]
|
|
|
|
|
self.y = self.BOARD_HEIGHT - 1
|
|
|
|
|
self.width = MAX_WIDTH[-1]
|
|
|
|
|
self.y = BOARD_HEIGHT - 1
|
|
|
|
|
|
|
|
|
|
def draw_text(self, string, height):
|
|
|
|
|
def __enter__(self) -> Slacker:
|
|
|
|
|
pygame.init()
|
|
|
|
|
set_caption('Slacker')
|
|
|
|
|
set_icon(image.load(self.data('icon.png')))
|
|
|
|
|
self.screen = set_mode(SCREEN_SIZE)
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def __exit__(self, *exc) -> None:
|
|
|
|
|
pygame.quit()
|
|
|
|
|
self.exit_stack.close()
|
|
|
|
|
|
|
|
|
|
def data(self, resource: str) -> str:
|
|
|
|
|
"""Return a true filesystem path for specified resource."""
|
|
|
|
|
return str(self.exit_stack.enter_context(
|
|
|
|
|
path('slacker_game', resource)))
|
|
|
|
|
|
|
|
|
|
def draw_text(self, string: str, height: float):
|
|
|
|
|
"""Width-fit the string in the screen on the given height."""
|
|
|
|
|
font = pygame.font.Font(
|
|
|
|
|
data('VT323-Regular.ttf'),
|
|
|
|
|
int(self.SCREEN_WIDTH * 2.5 / (len(string) + 1)))
|
|
|
|
|
text = font.render(string, False, self.COLOR_MINOR[0])
|
|
|
|
|
self.screen.blit(text, ((self.SCREEN_WIDTH - text.get_width()) // 2,
|
|
|
|
|
int(self.SCREEN_HEIGHT * height)))
|
|
|
|
|
font = Font(self.font, int(SCREEN_WIDTH*2.5/(len(string)+1)))
|
|
|
|
|
text = font.render(string, False, COLOR_MINOR[0])
|
|
|
|
|
self.screen.blit(text, ((SCREEN_WIDTH - text.get_width()) // 2,
|
|
|
|
|
int(SCREEN_HEIGHT * height)))
|
|
|
|
|
|
|
|
|
|
def intro(self):
|
|
|
|
|
def intro(self) -> None:
|
|
|
|
|
"""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)]:
|
|
|
|
|
SlackerTile(self.screen, *i, state=self.INTRO).draw(1.5)
|
|
|
|
|
if pygame.time.get_ticks() // 820 % 2:
|
|
|
|
|
SlackerTile(self.screen, *i, state=INTRO).draw(1.5)
|
|
|
|
|
if get_ticks() // 820 % 2:
|
|
|
|
|
self.draw_text('Press Spacebar', 0.75)
|
|
|
|
|
|
|
|
|
|
def draw_board(self):
|
|
|
|
|
def draw_board(self) -> None:
|
|
|
|
|
"""Draw the board and the tiles inside."""
|
|
|
|
|
for y, row in enumerate(self.board):
|
|
|
|
|
for x, block in enumerate(row):
|
|
|
|
@ -163,83 +172,85 @@ class Slacker:
|
|
|
|
|
else:
|
|
|
|
|
ft.draw()
|
|
|
|
|
|
|
|
|
|
def update_screen(self):
|
|
|
|
|
def update_screen(self) -> None:
|
|
|
|
|
"""Draw the whole screen and everything inside."""
|
|
|
|
|
self.screen.fill(self.BG_COLOR)
|
|
|
|
|
if self.game_state == self.INTRO:
|
|
|
|
|
self.screen.fill(BG_COLOR)
|
|
|
|
|
if self.game_state == INTRO:
|
|
|
|
|
self.intro()
|
|
|
|
|
elif self.game_state in (self.PLAYING, self.LOSE, self.WIN):
|
|
|
|
|
elif self.game_state in (PLAYING, LOSE, WIN):
|
|
|
|
|
self.draw_board()
|
|
|
|
|
pygame.display.flip()
|
|
|
|
|
flip()
|
|
|
|
|
|
|
|
|
|
def update_movement(self):
|
|
|
|
|
def update_movement(self) -> None:
|
|
|
|
|
"""Update the direction the blocks are moving in."""
|
|
|
|
|
speed = self.speed * self.speed_ratio
|
|
|
|
|
positions = self.BOARD_WIDTH + self.width - 2
|
|
|
|
|
p = int(round(pygame.time.get_ticks() / speed)) % (positions * 2)
|
|
|
|
|
positions = BOARD_WIDTH + self.width - 2
|
|
|
|
|
p = int(round(get_ticks()/speed)) % (positions*2)
|
|
|
|
|
self.x = (-p % positions if p > positions else p) - self.width + 1
|
|
|
|
|
self.board[self.y] = [0 <= x - self.x < self.width
|
|
|
|
|
for x in range(self.BOARD_WIDTH)]
|
|
|
|
|
for x in range(BOARD_WIDTH)]
|
|
|
|
|
|
|
|
|
|
def key_hit(self):
|
|
|
|
|
def key_hit(self) -> None:
|
|
|
|
|
"""Process the current position of the blocks relatively 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.
|
|
|
|
|
"""
|
|
|
|
|
if self.y < self.BOARD_HEIGHT - 1:
|
|
|
|
|
if self.y < BOARD_HEIGHT - 1:
|
|
|
|
|
for x in range(max(0, self.x),
|
|
|
|
|
min(self.x + self.width, self.BOARD_WIDTH)):
|
|
|
|
|
min(self.x + self.width, BOARD_WIDTH)):
|
|
|
|
|
# If there isn't any block underneath
|
|
|
|
|
if not self.board[self.y + 1][x]:
|
|
|
|
|
# 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=pygame.time.get_ticks()))
|
|
|
|
|
self.screen, x, self.y, missed_time=get_ticks()))
|
|
|
|
|
self.width = sum(self.board[self.y])
|
|
|
|
|
if not self.width:
|
|
|
|
|
self.game_state = self.LOSE
|
|
|
|
|
self.game_state = LOSE
|
|
|
|
|
elif not self.y:
|
|
|
|
|
self.game_state = self.WIN
|
|
|
|
|
self.game_state = WIN
|
|
|
|
|
else:
|
|
|
|
|
self.y -= 1
|
|
|
|
|
self.width = min(self.width, self.MAX_WIDTH[self.y])
|
|
|
|
|
self.speed = self.MAX_SPEED + self.y*self.SPEED_DIFF + randrange(5)
|
|
|
|
|
self.width = min(self.width, MAX_WIDTH[self.y])
|
|
|
|
|
self.speed = MAX_SPEED + self.y*SPEED_DIFF + randrange(5)
|
|
|
|
|
|
|
|
|
|
def main_loop(self, loop=True):
|
|
|
|
|
"""The main loop."""
|
|
|
|
|
while loop:
|
|
|
|
|
if self.game_state == self.INTRO:
|
|
|
|
|
for event in pygame.event.get():
|
|
|
|
|
if event.type == pygame.QUIT:
|
|
|
|
|
loop = False
|
|
|
|
|
elif event.type == pygame.KEYDOWN:
|
|
|
|
|
if event.key == pygame.K_SPACE:
|
|
|
|
|
self.game_state = self.PLAYING
|
|
|
|
|
elif event.key in (pygame.K_ESCAPE, pygame.K_q):
|
|
|
|
|
loop = False
|
|
|
|
|
def handle_intro(self) -> bool:
|
|
|
|
|
"""Handle events in intro."""
|
|
|
|
|
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
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif self.game_state == self.PLAYING:
|
|
|
|
|
for event in pygame.event.get():
|
|
|
|
|
if event.type == pygame.QUIT:
|
|
|
|
|
loop = False
|
|
|
|
|
elif event.type == pygame.KEYDOWN:
|
|
|
|
|
if event.key == pygame.K_SPACE:
|
|
|
|
|
self.key_hit()
|
|
|
|
|
elif event.key in (pygame.K_ESCAPE, pygame.K_q):
|
|
|
|
|
self.__init__()
|
|
|
|
|
# Yes, this is a cheat.
|
|
|
|
|
elif event.key == pygame.K_0:
|
|
|
|
|
self.width += self.width < self.BOARD_WIDTH
|
|
|
|
|
elif event.key in range(pygame.K_1, pygame.K_9+1):
|
|
|
|
|
self.speed_ratio = (pygame.K_9-event.key+1) / 5.0
|
|
|
|
|
self.update_movement()
|
|
|
|
|
def handle_playing(self) -> bool:
|
|
|
|
|
"""Handle events in game."""
|
|
|
|
|
for e in event.get():
|
|
|
|
|
if e.type == QUIT: return False
|
|
|
|
|
if e.type != KEYDOWN: continue
|
|
|
|
|
if e.key == K_SPACE:
|
|
|
|
|
self.key_hit()
|
|
|
|
|
elif e.key in (K_ESCAPE, K_q):
|
|
|
|
|
Slacker.__init__(self)
|
|
|
|
|
# Yes, these are cheats.
|
|
|
|
|
elif e.key == K_0:
|
|
|
|
|
self.width += self.width < BOARD_WIDTH
|
|
|
|
|
elif K_1 <= e.key <= K_9 + 1:
|
|
|
|
|
self.speed_ratio = (K_9-e.key+1) / 5.0
|
|
|
|
|
self.update_movement()
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif self.game_state in (self.LOSE, self.WIN):
|
|
|
|
|
for event in pygame.event.get():
|
|
|
|
|
if event.type == pygame.QUIT:
|
|
|
|
|
loop = False
|
|
|
|
|
elif event.type == pygame.KEYDOWN:
|
|
|
|
|
self.__init__(restart=True)
|
|
|
|
|
self.update_screen()
|
|
|
|
|
def handle_ending(self) -> bool:
|
|
|
|
|
"""Handle events in ending screens."""
|
|
|
|
|
for e in event.get():
|
|
|
|
|
if e.type == QUIT: return False
|
|
|
|
|
if e.type == KEYDOWN: Slacker.__init__(self, restart=True)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def handle_events(self) -> bool:
|
|
|
|
|
"""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
|
|
|
|
|