tkinter-space-game/leaderboard.py

444 lines
13 KiB
Python
Raw Permalink Normal View History

2023-01-05 11:35:28 +00:00
from os import path
from random import randint
from typing import List
from config import Config
from font import Font
from game import Game, GameSprite
class LeaderboardFile:
"""Object to manage saving and loading the leaderboard"""
def __init__(self):
"""Initialise the leaderboard file"""
self.entries = []
def load_entries(self):
"""Load leaderboard entries from a file"""
self.entries = []
if path.exists(Config.LEADERBOARD_FILE):
with open(Config.LEADERBOARD_FILE, "rb") as file:
while (entry := file.read(8 + Config.NICK_LEN)):
name = entry[0:Config.NICK_LEN]
score = entry[Config.NICK_LEN:11]
score_entry = (name.decode("ascii"),
int.from_bytes(score, byteorder="little"))
self.entries.append(score_entry)
self.sort_entries()
def sort_entries(self):
"""Sort leaderboard entries"""
self.entries.sort(key=(lambda e: e[1]))
self.entries.reverse()
def save_entries(self):
"""Save leaderboard entries"""
with open(Config.LEADERBOARD_FILE, "wb") as file:
for name, score in self.entries:
file.write(bytes(name[0:Config.NICK_LEN], "ascii"))
file.write(int(score).to_bytes(8, "little"))
def add_entry(self, name, score):
"""Add a leaderboard entry
:param name: Initials of the player
:param score: The sore that was achieved
"""
self.entries.append((name, score))
self.sort_entries()
class NameEntryLetter(GameSprite):
"""A single sprite used in a initial entry"""
def __init__(self, game: Game, image, letter):
"""Initialise the letter
:param game: The game which this belongs to
:type game: Game
:param image: The image to use for this
:param letter: the letter to use
"""
self.letter = letter
super().__init__(game, image)
class NameEntry():
"""An initial entry element, allowing the user to enter their initials"""
def __init__(self, game: Game, callback, num_letters=3, position=(0, 0)):
"""Initialise the name entry
:param game: The game which this belongs to
:type game: Game
:param callback: callback to call when the entry is complete
:param num_letters: Number of letters to use for the initials
:param position: Position of this element
"""
self.game = game
self.callback = callback
self.alphabet = [
Font.load_text(game.texture_factory, c)
for c in list(map(chr, range(97, 123)))
]
self.letters: List[NameEntryLetter] = []
self.selection = 0
self.hidden = True
self.populate_letters(num_letters)
self.game.inputs.add_keypress_handler(self.on_key)
self.set_pos(position)
def populate_letters(self, num_letters):
"""Create sprites for each of the letters
:param num_letters: Number of letters to use for initials
"""
for _ in range(num_letters):
sprite = NameEntryLetter(
self.game, self.alphabet[0], 0
)
self.letters.append(sprite)
enter_image = Font.load_text(self.game.texture_factory, "enter")
self.button = GameSprite(self.game, enter_image)
self.w = self.button.w + (self.letters[0].w+1)*len(self.letters)
self.h = Font.FONT_SIZE
def on_key(self, _):
"""Handle Key press events
:param _: The key press event to handle
"""
inp = self.game.inputs
if not self.hidden:
if inp.k_action and self.selection == len(self.letters):
self.callback(self.get_string())
return True
if inp.k_left:
self.selection -= 1
self.get_selected_letter().show()
if inp.k_right or inp.k_action:
self.get_selected_letter().show()
self.selection += 1
self.get_selected_letter().hide()
self.selection %= len(self.letters) + 1
if inp.k_up:
self.modify_letter(-1)
if inp.k_down:
self.modify_letter(1)
return True
return False
def get_string(self):
"""Get the initials entered"""
return "".join(map(lambda l: chr(97 + l.letter), self.letters))
def modify_letter(self, amount):
"""Increase or decrease a single character
:param amount: number of letters to increment by
"""
letter = self.get_selected_letter()
if letter in self.letters:
letter.letter += amount
letter.letter %= len(self.alphabet)
self.update_letter(letter)
letter.show()
def update_letter(self, letter):
"""Upare the image of a single letter
:param letter: letter to update
"""
letter.set_image(self.alphabet[letter.letter])
def get_selected_letter(self):
"""Get the letter that has been selected"""
if self.selection < len(self.letters):
return self.letters[self.selection]
return self.button
def set_pos(self, pos):
"""set the element's position
:param pos: position to move to
"""
pos_x, pos_y = pos
offset_x = 0
for letter in self.letters:
letter.set_pos((pos_x + offset_x, pos_y))
offset_x += letter.w + 1
offset_x += Font.FONT_SIZE
self.button.set_pos((pos_x + offset_x, pos_y))
def update_position(self):
"""Update the position of all the letters"""
for letter in self.letters:
letter.update_position()
def show(self):
"""Make this object visible"""
if self.hidden:
for letter in self.letters:
letter.show()
letter.send_to_front()
self.button.show()
self.button.send_to_front()
self.hidden = False
def hide(self):
"""Make this object invisible"""
if not self.hidden:
for letter in self.letters:
letter.hide()
self.button.hide()
self.hidden = True
def tick(self):
"""Update the state of this object"""
if not self.hidden:
selected = self.get_selected_letter()
for letter in self.letters + [self.button]:
if self.game.alpha//15 == self.game.alpha / 15:
if letter == selected:
if (self.game.alpha//15) % 2 == 0:
self.get_selected_letter().show()
else:
self.get_selected_letter().hide()
else:
letter.show()
else:
self.hide()
class Leaderboard:
"""Leaderboard object to display previous scores"""
ANIMATION_TIME = 5
ANIMATION_DELAY = 5
def __init__(self, game: Game):
"""Initialise the leaderboard
:param game: The game which this belongs to
:type game: Game
"""
self.game = game
self.file = LeaderboardFile()
self.entries = []
self.editing = True
self.padding = 5
self.callback = (lambda: None)
self.hidden = True
self.game.inputs.add_keypress_handler(self.on_key)
self.name_entry = NameEntry(self.game, self.submit_name)
self.blinking_sprite = None
self.animation_start = -1
def populate_entries(self, blink_entry=("", 0)):
"""Populate entries.
:param blink_entry:
"""
self.clear_entries()
self.file.load_entries()
editing_area = 0
if self.editing:
editing_area = Font.FONT_SIZE + self.padding*2
remaining_area = self.game.h - self.padding*2 - editing_area
to_fit = remaining_area // (Font.FONT_SIZE+self.padding) - 1
to_draw = self.file.entries[0:to_fit]
# create a row variable that is incremented for each entry
y = self.padding
# create the title sprite and increment the row
image = Font.load_text(self.game.texture_factory, "leaderboard")
sprite = GameSprite(self.game, image)
sprite.set_pos((0, y))
self.entries.append(sprite)
x = (self.game.w - sprite.w) // 2
sprite.set_pos((x, y))
y += sprite.h + self.padding
# calculate the number of zeros to pad the score by
zfill = ((self.game.w-self.padding*2) //
(Font.FONT_SIZE+1)) - Config.NICK_LEN - 5
for name, score in to_draw:
text = f"{name} {str(score).zfill(zfill)}"
x = self.padding
image = Font.load_text(self.game.texture_factory, text)
sprite = GameSprite(self.game, image)
sprite.set_pos((x, y))
if (name, score) == blink_entry:
self.blinking_sprite = sprite
self.entries.append(sprite)
y += sprite.h + self.padding
if self.editing:
self.name_entry.set_pos((self.padding, y+self.padding))
self.name_entry.show()
else:
self.name_entry.hide()
def start_animation(self):
"""Start the animation."""
for e in self.entries:
e.set_pos((-self.game.w, e.y))
self.name_entry.hide()
self.animation_start = self.game.alpha
def on_key(self, _):
"""Handle Key press events
:param _: The key press event to handle
"""
inp = self.game.inputs
if not self.hidden and inp.k_action and not self.editing:
self.callback()
return True
return False
def submit_name(self, name):
"""Submit a name to the leaderboard
:param name:
"""
score = self.game.score
self.file.add_entry(name, score)
self.file.save_entries()
self.editing = False
self.name_entry.hide()
self.populate_entries(blink_entry=(name, score))
self.start_animation()
for e in self.entries:
e.show()
def animate_sprite(self, sprite, i):
"""Animate a single sprite.
:param sprite:
:param i:
"""
alpha = self.game.alpha \
- self.animation_start \
- i*Leaderboard.ANIMATION_DELAY
if alpha <= Leaderboard.ANIMATION_TIME:
if i == 0:
# only title should be h aligned
cx = (self.game.w - sprite.w) // 2
else:
cx = self.padding
x = (alpha/Leaderboard.ANIMATION_TIME) * \
(cx+self.game.w//2) - self.game.w//2
sprite.set_pos((x, sprite.y))
return False
return True
def tick(self):
"""Update the leaderboard"""
animation_complete = True
for i, sprite in enumerate(self.entries):
sprite.send_to_front()
if not self.animate_sprite(sprite, i):
animation_complete = False
if self.editing:
animation_time = self.game.alpha \
- self.animation_start \
- len(self.entries)*Leaderboard.ANIMATION_DELAY
if animation_complete \
and animation_time > Leaderboard.ANIMATION_TIME:
self.name_entry.show()
self.name_entry.tick()
else:
if self.blinking_sprite is not None:
if (self.game.alpha//15) % 2 == 0:
self.blinking_sprite.show()
else:
self.blinking_sprite.hide()
def show(self):
"""Make this object visible"""
if self.hidden:
for m in self.entries:
m.show()
m.send_to_front()
self.hidden = False
def hide(self):
"""Make this object invisible"""
if not self.hidden:
for m in self.entries:
m.hide()
self.name_entry.hide()
self.hidden = True
def start_editing(self):
"""Allow the user to input a name"""
self.editing = True
self.blinking_sprite = None
def clear_entries(self):
"""Remove all the associated objects"""
for entry in self.entries:
entry.destroy()
self.entries = []
# test to add entries to game leaderboard
if __name__ == "__main__":
lb = LeaderboardFile()
lb.load_entries()
for input_name, input_score in lb.entries:
print(f"{input_name} {input_score}")
while True:
input_name = input("Enter name or leave blank to exit: ")
if input_name:
input_score = input("enter score blank for random: ")
if not input_score:
input_score = randint(1, 999999)
lb.add_entry(input_name, int(input_score))
else:
break
for input_name, input_score in lb.entries:
print(f"{input_name} {input_score}")
lb.save_entries()