├── .gitignore ├── requirements.txt ├── assets ├── ssh.gif └── ascii │ ├── game_over.txt │ └── logo.txt ├── data └── scores.db ├── __pycache__ ├── game.cpython-312.pyc └── game.cpython-313.pyc ├── menu.py ├── quickstart.sh ├── README.md ├── game_over.py ├── database.py ├── game.py └── main.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/* 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | blessed==1.21.0 2 | -------------------------------------------------------------------------------- /assets/ssh.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullghostty/vim-wizards/HEAD/assets/ssh.gif -------------------------------------------------------------------------------- /data/scores.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullghostty/vim-wizards/HEAD/data/scores.db -------------------------------------------------------------------------------- /__pycache__/game.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullghostty/vim-wizards/HEAD/__pycache__/game.cpython-312.pyc -------------------------------------------------------------------------------- /__pycache__/game.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullghostty/vim-wizards/HEAD/__pycache__/game.cpython-313.pyc -------------------------------------------------------------------------------- /assets/ascii/game_over.txt: -------------------------------------------------------------------------------- 1 | ╔═╗╔═╗╔╦╗╔═╗ ╔═╗╦ ╦╔═╗╦═╗ 2 | ║ ╦╠═╣║║║║╣ ║ ║╚╗╔╝║╣ ╠╦╝ 3 | ╚═╝╩ ╩╩ ╩╚═╝ ╚═╝ ╚╝ ╚═╝╩╚═ 4 | -------------------------------------------------------------------------------- /assets/ascii/logo.txt: -------------------------------------------------------------------------------- 1 | ╦ ╦╦╔╦╗╦ ╦╦╔═╗╔═╗╦═╗╔╦╗╔═╗ 2 | ╚╗╔╝║║║║║║║║╔═╝╠═╣╠╦╝ ║║╚═╗ 3 | ╚╝ ╩╩ ╩╚╩╝╩╚═╝╩ ╩╩╚══╩╝╚═╝ 4 | 5 | 6 | -------------------------------------------------------------------------------- /menu.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Menu system for VimWizards game 4 | """ 5 | 6 | from blessed import Terminal 7 | from database import get_top_high_scores 8 | 9 | class Menu: 10 | def __init__(self): 11 | self.term = Terminal() 12 | self.logo = self.load_logo("assets/ascii/logo.txt") 13 | self.options = ['Start Game', 'High Scores', 'Quit'] 14 | self.selected = 0 15 | 16 | def load_logo(self,path): 17 | try: 18 | with open(path, "r", encoding="utf-8") as file: 19 | return file.read() 20 | except FileNotFoundError: 21 | return "[Logo file not found]" 22 | 23 | def display_high_scores(self): 24 | """Display the high scores screen.""" 25 | with self.term.cbreak(), self.term.hidden_cursor(): 26 | while True: 27 | # Clear screen 28 | print('\033[2J\033[3J\033[H') 29 | 30 | # Display header 31 | print("\tHIGH SCORES") 32 | print("\t" + "=" * 50) 33 | print() 34 | 35 | # Get top 10 scores 36 | scores = get_top_high_scores(10) 37 | 38 | if not scores: 39 | print("\tNo high scores yet!") 40 | print("\tBe the first to set a record!") 41 | else: 42 | print(f"\t{'Rank':<6} {'Initials':<10} {'Score':<10} {'Date'}") 43 | print("\t" + "-" * 50) 44 | 45 | for i, (initials, score, date) in enumerate(scores, 1): 46 | # Format date to show just the date part (YYYY-MM-DD) 47 | formatted_date = date.split()[0] if ' ' in date else date 48 | print(f"\t{i:<6} {initials:<10} {score:<10} {formatted_date}") 49 | 50 | print() 51 | print("\tPress any key to return to main menu...") 52 | 53 | # Wait for any key press 54 | self.term.inkey() 55 | return 56 | 57 | def display(self): 58 | # Display the menu and handle user input 59 | with self.term.cbreak(), self.term.hidden_cursor(): 60 | while True: 61 | # Clear screen 62 | print('\033[2J\033[3J\033[H') 63 | 64 | # Display logo 65 | print(self.logo) 66 | print() 67 | 68 | # Display menu options 69 | for i, option in enumerate(self.options): 70 | if i == self.selected: 71 | print(f"\t\t> {option}") 72 | else: 73 | print(f"\t\t {option}") 74 | 75 | print() 76 | print("\tUse j/k to navigate, Enter to select") 77 | 78 | # Get user input 79 | key = self.term.inkey() 80 | 81 | if key.lower() == 'j' and self.selected < len(self.options) - 1: 82 | self.selected += 1 83 | elif key.lower() == 'k' and self.selected > 0: 84 | self.selected -= 1 85 | elif key.name == 'KEY_ENTER': 86 | if self.selected == 0: # Start Game 87 | return True 88 | elif self.selected == 1: # High Scores 89 | self.display_high_scores() 90 | # Continue the menu loop after returning from high scores 91 | else: # Quit 92 | return False 93 | -------------------------------------------------------------------------------- /quickstart.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Colors for output 4 | GREEN='\033[0;32m' 5 | YELLOW='\033[1;33m' 6 | RED='\033[0;31m' 7 | NC='\033[0m' # No Color 8 | 9 | echo -e "${GREEN}Welcome to VimWizards Setup!${NC}" 10 | echo "==============================" 11 | echo 12 | 13 | # Check if Python is installed 14 | if ! command -v python3 &> /dev/null; then 15 | echo -e "${RED}Error: Python 3 is not installed.${NC}" 16 | echo "Please install Python 3 and try again." 17 | exit 1 18 | fi 19 | 20 | # Check Python version 21 | PYTHON_VERSION=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))') 22 | echo -e "${GREEN}Found Python ${PYTHON_VERSION}${NC}" 23 | 24 | # Check if pip is installed 25 | if ! python3 -m pip --version &> /dev/null; then 26 | echo -e "${YELLOW}pip is not installed.${NC}" 27 | read -p "Would you like to install pip? (y/n): " -n 1 -r 28 | echo 29 | if [[ $REPLY =~ ^[Yy]$ ]]; then 30 | echo "Installing pip..." 31 | curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py 32 | python3 get-pip.py --user 33 | rm get-pip.py 34 | 35 | # Add pip to PATH for current session 36 | export PATH="$HOME/.local/bin:$PATH" 37 | 38 | # Verify pip installation 39 | if ! python3 -m pip --version &> /dev/null; then 40 | echo -e "${RED}Failed to install pip. Please install it manually.${NC}" 41 | exit 1 42 | fi 43 | echo -e "${GREEN}pip installed successfully!${NC}" 44 | else 45 | echo -e "${RED}pip is required to continue. Exiting.${NC}" 46 | exit 1 47 | fi 48 | fi 49 | 50 | # Check if venv module is available 51 | if ! python3 -c "import venv" &> /dev/null; then 52 | echo -e "${RED}Error: venv module is not available.${NC}" 53 | echo "You may need to install python3-venv package:" 54 | echo " Ubuntu/Debian: sudo apt-get install python3-venv" 55 | echo " Fedora: sudo dnf install python3-venv" 56 | exit 1 57 | fi 58 | 59 | # Check if virtual environment exists 60 | if [ ! -d ".venv" ]; then 61 | echo -e "${YELLOW}Virtual environment not found. Creating .venv...${NC}" 62 | python3 -m venv .venv 63 | 64 | if [ $? -eq 0 ]; then 65 | echo -e "${GREEN}Virtual environment created successfully!${NC}" 66 | else 67 | echo -e "${RED}Failed to create virtual environment.${NC}" 68 | exit 1 69 | fi 70 | else 71 | echo -e "${GREEN}Virtual environment already exists.${NC}" 72 | fi 73 | 74 | # Activate virtual environment 75 | echo "Activating virtual environment..." 76 | source .venv/bin/activate 77 | 78 | if [ $? -ne 0 ]; then 79 | echo -e "${RED}Failed to activate virtual environment.${NC}" 80 | exit 1 81 | fi 82 | 83 | # Check if requirements.txt exists 84 | if [ ! -f "requirements.txt" ]; then 85 | echo -e "${YELLOW}requirements.txt not found. Creating with necessary dependencies...${NC}" 86 | cat > requirements.txt << EOF 87 | blessed>=1.19.0 88 | EOF 89 | fi 90 | 91 | # Install dependencies 92 | echo "Installing dependencies from requirements.txt..." 93 | pip install -r requirements.txt 94 | 95 | if [ $? -eq 0 ]; then 96 | echo -e "${GREEN}Dependencies installed successfully!${NC}" 97 | else 98 | echo -e "${RED}Failed to install dependencies.${NC}" 99 | exit 1 100 | fi 101 | 102 | # Check if main.py exists 103 | if [ ! -f "main.py" ]; then 104 | echo -e "${RED}Error: main.py not found in current directory.${NC}" 105 | echo "Make sure you're running this script from the VimWizards project directory." 106 | exit 1 107 | fi 108 | 109 | # Launch the game 110 | echo 111 | echo -e "${GREEN}Setup complete! Launching VimWizards...${NC}" 112 | echo "======================================" 113 | echo 114 | 115 | python main.py 116 | 117 | # Deactivate virtual environment when game exits 118 | deactivate 2>/dev/null || true 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VimWizards 2 | 3 | ``` 4 | ╦ ╦╦╔╦╗╦ ╦╦╔═╗╔═╗╦═╗╔╦╗╔═╗ 5 | ╚╗╔╝║║║║║║║║╔═╝╠═╣╠╦╝ ║║╚═╗ 6 | ╚╝ ╩╩ ╩╚╩╝╩╚═╝╩ ╩╩╚══╩╝╚═╝ 7 | ``` 8 | 9 | A terminal-based arcade game where you control a wizard using Vim movement commands. Navigate the arena, collect power crystals, and avoid your growing ley-trail in this retro-style challenge. 10 | 11 | VimWizards is a hackathon project made thanks to [boot.dev](https://blog.boot.dev/news/hackathon-2025/). 12 | 13 | ## Play Now 14 | [![play over ssh](assets/ssh.gif)](https://youtu.be/-OxVP4ZrVDE) 15 | 16 | ### Option 1: Play Online (Recommended) 17 | ```bash 18 | ssh vimwizard@vimwizards.sh 19 | ``` 20 | Compete with other players on the global leaderboard! 21 | 22 | ### Option 2: Play Locally 23 | 24 | **Requirements**: 25 | - Python 3 26 | - Pip 27 | 28 | ```bash 29 | git clone https://github.com/ulises-gomez-dev/vim-wizards.git 30 | cd vim-wizards 31 | ./quickstart.sh 32 | ``` 33 | 34 | ## Game Overview 35 | 36 | VimWizards combines the precision of Vim movement commands with classic snake-game mechanics. Your wizard (W) navigates an arena collecting crystals while avoiding collision with their own magical trail. 37 | 38 | ### Arena States 39 | 40 | **Starting Arena:** 41 | ``` 42 | A B C D E F G H I J 43 | +----------------------+ 44 | 1 | W . . . . . . . . . | 45 | 2 | . . . . . . . . . . | 46 | 3 | . . . . . . . . . . | 47 | 4 | . . . . . . . . . . | 48 | 5 | . . . . ♦ . . . . . | 49 | 6 | . . . . . . . . . . | 50 | 7 | . . . . . . . . . . | 51 | 8 | . . . . . . . . . . | 52 | 9 | . . . . . . . . . . | 53 | 10 | . . . . . . . . . . | 54 | +----------------------+ 55 | ``` 56 | 57 | **Mid-Game with Trail:** 58 | ``` 59 | A B C D E F G H I J 60 | +----------------------+ 61 | 1 | . . . . . . . . . . | 62 | 2 | . . . . . o . . . . | 63 | 3 | . . . . W o . . . . | 64 | 4 | . . . . o o . . . . | 65 | 5 | . . . . . . . ♦ . . | 66 | 6 | . . . . . . . . . . | 67 | 7 | . . . . . . . . . . | 68 | 8 | . . . . . . . . . . | 69 | 9 | . . . . . . . . . . | 70 | 10 | . . . . . . . . . . | 71 | +----------------------+ 72 | ``` 73 | 74 | **Playing with Portals:** 75 | ``` 76 | A B C D E F G H I J 77 | +----------------------+ 78 | 1 | . . . . . . . . . . | 79 | 2 | . . . . @ o o o o o | 80 | 3 | . . . . . . . . . o | 81 | 4 | . . . . @ . . . . o | 82 | 5 | . . . . o . . . . o | 83 | 6 | o . . . W . . . . o | 84 | 7 | o . . . . . . ♦ . o | 85 | 8 | o . . . . . . . . o | 86 | 9 | o o o o o o o o o o | 87 | 10 | . . . . . . . . . . | 88 | +----------------------+ 89 | ``` 90 | 91 | ### Controls 92 | 93 | - **h** - Move left 94 | - **j** - Move down 95 | - **k** - Move up 96 | - **l** - Move right 97 | - **0** - Teleport to start of row 98 | - **$** - Teleport to end of row 99 | - **#G** - Create portal (# = row number) 100 | - **:q!** - Quit game 101 | 102 | ## Architecture 103 | 104 | VimWizards is built with clean, modular Python code: 105 | 106 | ### Core Components 107 | 108 | **main.py** - Game Loop & Input Handler 109 | - Manages terminal I/O using the `blessed` library 110 | - Implements Vim-style movement command processing 111 | - Handles game state and continuous rendering loop 112 | - Coordinates between game objects and display 113 | 114 | **game.py** - Game Logic Classes 115 | - `Arena`: 2D grid management with *artisanal* coordinate system 116 | - Even-numbered X coordinates (0, 2, 4...) for proper spacing 117 | - Dynamic object rendering and cleanup 118 | - Border rendering with row/column labels 119 | - `Wizard`: Player character with movement and collision detection 120 | - Trail management that grows with collected crystals 121 | - Portal creation and teleportation "logic"... Silly wizard can lock themself out of their own portal. 122 | - `Crystal`: Collectible objects that increase score and orb trail length 123 | - Smart spawning that avoids occupied spaces 124 | 125 | **Supporting Modules** 126 | - `menu.py`: Main menu with ASCII art logo 127 | - `database.py`: SQLite score persistence 128 | - `game_over.py`: Game over screen with score entry 129 | 130 | ### Technical Details 131 | 132 | - **Coordinate System**: X coordinates use even numbers (0, 2, 4...) due to terminal character spacing 133 | - **Movement Vectors**: Adjusted for double-spaced grid (h: -2, l: +2, j: +1, k: -1) 134 | - **Rendering**: ANSI escape sequences for efficient screen clearing 135 | - **Input Mode**: Terminal `cbreak()` mode for immediate key response 136 | - **State Management**: Clean separation between game logic and display 137 | 138 | ## Requirements 139 | 140 | - Python 3.7+ 141 | - blessed (terminal formatting library) 142 | 143 | ## Development 144 | 145 | ### Key Design Patterns 146 | - Object-oriented design for game entities 147 | - Composition for wizard-arena relationship 148 | - Event-driven input handling 149 | - State pattern for game flow 150 | 151 | ## Contributing 152 | 153 | Feel free to submit issues and enhancement requests! 154 | We plan to add additional features like a near infinitely expanding arena, better layout, enemies that want you dead, and MORE VIM COMMANDS! 155 | 156 | ## License 157 | 158 | MIT License - see LICENSE file for details 159 | 160 | -------------------------------------------------------------------------------- /game_over.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Game Over screen module for VimWizards. 4 | Handles the game over display, initials input, and score saving using blessed terminal. 5 | """ 6 | 7 | from datetime import datetime 8 | from blessed import Terminal 9 | from database import save_high_score 10 | 11 | 12 | class GameOverScreen: 13 | """Handles the game over screen display and user input.""" 14 | 15 | def __init__(self): 16 | """Initialize the game over screen with terminal.""" 17 | self.term = Terminal() 18 | 19 | def load_ascii_art(self): 20 | """Load the ASCII art from file.""" 21 | try: 22 | with open("assets/ascii/game_over.txt", 'r', encoding='utf-8') as file: 23 | return file.read() 24 | except FileNotFoundError: 25 | return "GAME OVER" 26 | except Exception: 27 | return "GAME OVER" 28 | 29 | def clear_screen(self): 30 | """Clear the terminal screen using ANSI escape sequences.""" 31 | print('\033[2J\033[3J\033[H', end='', flush=True) 32 | 33 | def display_game_over(self, score, message=""): 34 | """Display the game over screen with ASCII art and score.""" 35 | self.clear_screen() 36 | 37 | # Show ASCII art 38 | ascii_art = self.load_ascii_art() 39 | print(ascii_art) 40 | 41 | # Show score 42 | print(f"\tFinal Score: {score}") 43 | print() 44 | 45 | # Show additional message if provided 46 | if message: 47 | print(f"\t{message}") 48 | print() 49 | 50 | def get_player_initials(self): 51 | """ 52 | Get 3-character initials from the player using blessed terminal. 53 | Allows character-by-character input with Enter to submit when 3 characters. 54 | """ 55 | initials = "" 56 | 57 | while True: 58 | self.display_game_over(0, "Enter your initials (3 characters):") 59 | print(f"\tInitials: {initials}_") 60 | print() 61 | print("\tEnter 3 letters for your initials") 62 | if len(initials) == 3: 63 | print("\tPress Enter to submit or Backspace to edit") 64 | 65 | with self.term.cbreak(): 66 | key = self.term.inkey() 67 | 68 | if key.name == 'KEY_ESCAPE': 69 | # User pressed Escape, cancel entry 70 | return "" 71 | elif key.name == 'KEY_BACKSPACE' or key == '\x7f': 72 | # Backspace - remove last character 73 | if initials: 74 | initials = initials[:-1] 75 | elif key.name == 'KEY_ENTER' or key == '\r' or key == '\n': 76 | # Enter - submit if we have 3 characters 77 | if len(initials) == 3: 78 | return initials.upper() 79 | # Otherwise ignore Enter if not 3 characters 80 | elif key.isalpha() and len(initials) < 3: 81 | # Add letter if we don't have 3 yet 82 | initials += key.upper() 83 | 84 | # Auto-submit when we reach 3 characters and user presses Enter 85 | if len(initials) == 3: 86 | # Show the completed initials 87 | self.display_game_over(0, "Enter your initials (3 characters):") 88 | print(f"\tInitials: {initials}") 89 | print() 90 | print("\tPress Enter to submit or Backspace to edit") 91 | 92 | # Wait for Enter or continue editing 93 | with self.term.cbreak(): 94 | next_key = self.term.inkey() 95 | if next_key.name == 'KEY_ENTER' or next_key == '\r' or next_key == '\n': 96 | return initials.upper() 97 | elif next_key.name == 'KEY_BACKSPACE' or next_key == '\x7f': 98 | initials = initials[:-1] 99 | elif next_key.name == 'KEY_ESCAPE': 100 | return "" 101 | 102 | def save_score(self, initials, score): 103 | """Save the score to the database.""" 104 | current_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 105 | return save_high_score(initials, score, current_date) 106 | 107 | def wait_for_continue(self, message="Press any key to continue..."): 108 | """Wait for user to press any key.""" 109 | print(f"\t{message}") 110 | with self.term.cbreak(): 111 | self.term.inkey() 112 | 113 | def show(self, score): 114 | """ 115 | Display the game over screen and handle score entry. 116 | 117 | Args: 118 | score: The player's final score 119 | """ 120 | # Show initial game over screen 121 | self.display_game_over(score) 122 | self.wait_for_continue() 123 | 124 | # Get player initials 125 | initials = self.get_player_initials() 126 | 127 | # If user cancelled, don't save score 128 | if not initials: 129 | self.display_game_over(score, "Score not saved.") 130 | self.wait_for_continue() 131 | return 132 | 133 | # Save score to database 134 | save_success = self.save_score(initials, score) 135 | 136 | # Show confirmation 137 | if save_success: 138 | message = f"Score saved successfully for {initials}!" 139 | else: 140 | message = "Error saving score to database." 141 | 142 | self.display_game_over(score, message) 143 | self.wait_for_continue() 144 | 145 | 146 | def test_game_over(): 147 | """Test the game over functionality.""" 148 | print("Testing Game Over screen...") 149 | game_over = GameOverScreen() 150 | game_over.show_game_over(42) 151 | print("Game Over test completed.") 152 | 153 | 154 | if __name__ == "__main__": 155 | test_game_over() 156 | -------------------------------------------------------------------------------- /database.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Database module for VimWizards high score management. 4 | """ 5 | 6 | import sqlite3 7 | import os 8 | from datetime import datetime 9 | from typing import List, Tuple, Optional 10 | 11 | 12 | class ScoreDatabase: 13 | 14 | def __init__(self, db_path: str = "./data/scores.db"): 15 | # Initialize database connection. 16 | self._db_path = db_path 17 | self.init_database() 18 | 19 | def init_database(self) -> None: 20 | # Create the database and high_scores table if they don't exist. 21 | try: 22 | with sqlite3.connect(self._db_path) as conn: 23 | conn.execute(""" 24 | CREATE TABLE IF NOT EXISTS high_scores ( 25 | id INTEGER PRIMARY KEY AUTOINCREMENT, 26 | initials TEXT NOT NULL CHECK(length(initials) = 3), 27 | score INTEGER NOT NULL CHECK(score >= 0), 28 | date TEXT NOT NULL 29 | ) 30 | """) 31 | conn.commit() 32 | except sqlite3.Error as e: 33 | print(f"Database initialization error: {e}") 34 | 35 | def save_score(self, initials: str, score: int, date: Optional[str] = None) -> bool: 36 | """ 37 | Save a new high score entry. 38 | 39 | Args: 40 | initials: Player's 3-character initials (will be converted to uppercase) 41 | score: Player's score (must be non-negative) 42 | date: Optional date string (defaults to current date/time) 43 | 44 | Returns: 45 | True if save was successful, False otherwise 46 | """ 47 | if len(initials) != 3: 48 | print("Error: Initials must be exactly 3 characters") 49 | return False 50 | 51 | if score < 0: 52 | print("Error: Score cannot be negative") 53 | return False 54 | 55 | if date is None: 56 | date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 57 | 58 | try: 59 | with sqlite3.connect(self._db_path) as conn: 60 | conn.execute( 61 | "INSERT INTO high_scores (initials, score, date) VALUES (?, ?, ?)", 62 | (initials.upper(), score, date) 63 | ) 64 | conn.commit() 65 | return True 66 | except sqlite3.Error as e: 67 | print(f"Error saving score: {e}") 68 | return False 69 | 70 | def get_top_scores(self, limit: int = 10) -> List[Tuple[str, int, str]]: 71 | """ 72 | Get the top high scores ordered by score descending. 73 | 74 | Args: 75 | limit: Maximum number of scores to return (default 10) 76 | 77 | Returns: 78 | List of tuples containing (initials, score, date) 79 | """ 80 | try: 81 | with sqlite3.connect(self._db_path) as conn: 82 | cursor = conn.execute( 83 | "SELECT initials, score, date FROM high_scores ORDER BY score DESC LIMIT ?", 84 | (limit,) 85 | ) 86 | return cursor.fetchall() 87 | except sqlite3.Error as e: 88 | print(f"Error retrieving scores: {e}") 89 | return [] 90 | 91 | def get_score_count(self) -> int: 92 | """Get the total number of scores in the database.""" 93 | try: 94 | with sqlite3.connect(self._db_path) as conn: 95 | cursor = conn.execute("SELECT COUNT(*) FROM high_scores") 96 | return cursor.fetchone()[0] 97 | except sqlite3.Error as e: 98 | print(f"Error counting scores: {e}") 99 | return 0 100 | 101 | def clear_scores(self) -> bool: 102 | """Clear all scores from the database. Use with caution.""" 103 | try: 104 | with sqlite3.connect(self._db_path) as conn: 105 | conn.execute("DELETE FROM high_scores") 106 | conn.commit() 107 | return True 108 | except sqlite3.Error as e: 109 | print(f"Error clearing scores: {e}") 110 | return False 111 | 112 | 113 | def init_database(db_path: str = "scores.db") -> None: 114 | """ 115 | Initialize the high scores database. 116 | 117 | Args: 118 | db_path: Path to the database file (default: scores.db) 119 | """ 120 | db = ScoreDatabase(db_path) 121 | 122 | 123 | def save_high_score(initials: str, score: int, date: Optional[str] = None, db_path: str = "scores.db") -> bool: 124 | """ 125 | Save a new high score entry. 126 | 127 | Args: 128 | initials: Player's 3-character initials 129 | score: Player's score 130 | date: Optional date string (defaults to current date/time) 131 | db_path: Path to the database file 132 | 133 | Returns: 134 | True if save was successful, False otherwise 135 | """ 136 | db = ScoreDatabase(db_path) 137 | return db.save_score(initials, score, date) 138 | 139 | 140 | def get_top_high_scores(limit: int = 10, db_path: str = "scores.db") -> List[Tuple[str, int, str]]: 141 | """ 142 | Get the top high scores ordered by score descending. 143 | 144 | Args: 145 | limit: Maximum number of scores to return (default 10) 146 | db_path: Path to the database file 147 | 148 | Returns: 149 | List of tuples containing (initials, score, date) 150 | """ 151 | db = ScoreDatabase(db_path) 152 | return db.get_top_scores(limit) 153 | 154 | 155 | def test_database(): 156 | """Test the database functionality.""" 157 | # Use a test database file 158 | test_db = "test_scores.db" 159 | 160 | print("Testing database functionality...") 161 | 162 | # Initialize database 163 | init_database(test_db) 164 | print("Database initialized") 165 | 166 | # Save some test scores 167 | test_scores = [ 168 | ("AAA", 100), 169 | ("BBB", 250), 170 | ("CCC", 175), 171 | ("DDD", 300), 172 | ("EEE", 50) 173 | ] 174 | 175 | for initials, score in test_scores: 176 | if save_high_score(initials, score, db_path=test_db): 177 | print(f"Saved score: {initials} - {score}") 178 | else: 179 | print(f"Failed to save score: {initials} - {score}") 180 | 181 | # Retrieve and display top scores 182 | print("\nTop 5 High Scores:") 183 | top_scores = get_top_high_scores(5, test_db) 184 | for i, (initials, score, date) in enumerate(top_scores, 1): 185 | print(f"{i}. {initials} - {score} ({date})") 186 | 187 | # Clean up test database 188 | if os.path.exists(test_db): 189 | os.remove(test_db) 190 | print(f"\nTest database {test_db} removed") 191 | 192 | 193 | if __name__ == "__main__": 194 | test_database() 195 | -------------------------------------------------------------------------------- /game.py: -------------------------------------------------------------------------------- 1 | import random 2 | from random import randrange 3 | 4 | 5 | class Arena: 6 | def __init__(self, size=10): 7 | self._size = size 8 | self._column_size = (size * 2) - 1 9 | self._row_size = size 10 | self._arena = [ 11 | ["." if (c % 2 == 0) else " " for c in range(self._column_size)] 12 | for r in range(self._row_size) 13 | ] 14 | self._top_down_border = f" +{'-' * (self._column_size + 2)}+\n" 15 | self._rendered_objects_percentage = 2 16 | 17 | @property 18 | def arena(self): 19 | """The arena property.""" 20 | return self._arena 21 | 22 | @arena.setter 23 | def arena(self, value): 24 | self._arena = value 25 | 26 | def render_object_to_arena(self, position, symbol): 27 | x, y = position 28 | self._arena[y][x] = symbol 29 | 30 | def clean_up_wizard(self, position): 31 | x, y = position 32 | self._arena[y][x] = "." 33 | 34 | def __repr__(self) -> str: 35 | # 4 empty space characters 36 | render = " " 37 | 38 | # ASCII values for uppercase letters A - Z range from 65 to 90 39 | for i in range(65, 65 + self._size): 40 | render += f"{chr(i)} " 41 | 42 | render += " \n" 43 | render += self._top_down_border 44 | 45 | # self.wizzard_position() 46 | 47 | for r in range(self._row_size): 48 | ln = r + 1 49 | if ln < 10: 50 | render += " " 51 | 52 | render += f"{ln} | " 53 | 54 | for c in range(self._column_size): 55 | render += self._arena[r][c] 56 | 57 | render += " |\n" 58 | 59 | render += self._top_down_border 60 | return render 61 | 62 | 63 | class Wizard: 64 | def __init__(self, x, y, arena): 65 | self._symbol = "W" 66 | self._arena = arena 67 | self._x = x 68 | self._y = y 69 | self._crystals = 0 70 | self._tail = [] 71 | self._tail_symbol = "o" 72 | self._portal_entry = None 73 | self._portal_exit = None 74 | self._portal_symbol = "@" 75 | 76 | self.render_wizard_to_arena() 77 | 78 | @property 79 | def position(self): 80 | """The position property.""" 81 | return (self._x, self._y) 82 | 83 | @position.setter 84 | def position(self, position): 85 | self._arena.clean_up_wizard(self.position) 86 | 87 | # Update tail positions 88 | if self._tail: 89 | # Clean up the last tail segment 90 | if len(self._tail) >= self._crystals: 91 | last_segment = self._tail.pop() 92 | self._arena.clean_up_wizard(last_segment) 93 | 94 | # Add current position to front of tail 95 | self._tail.insert(0, self.position) 96 | 97 | # Render the tail 98 | for segment in self._tail: 99 | self._arena.render_object_to_arena(segment, self._tail_symbol) 100 | 101 | self._x, self._y = position 102 | self.render_wizard_to_arena() 103 | 104 | # Re-render portals if they exist (in case they were overwritten) 105 | if self._portal_entry: 106 | self._arena.render_object_to_arena(self._portal_entry, self._portal_symbol) 107 | if self._portal_exit: 108 | self._arena.render_object_to_arena(self._portal_exit, self._portal_symbol) 109 | 110 | # TODO: place in a Arena object helper function 111 | objects_rendered = len(self._tail) + 2 112 | size = self._arena._size 113 | self._arena._rendered_objects_percentage = int((objects_rendered / (size * size)) * 100) 114 | 115 | @property 116 | def crystals(self): 117 | return self._crystals 118 | 119 | def collect_crystals(self, crystal): 120 | self._crystals += 1 121 | # When collecting a crystal, add current position to tail 122 | if self.position not in self._tail: 123 | self._tail.insert(0, self.position) 124 | 125 | crystal.spawn(self) 126 | 127 | def render_wizard_to_arena(self): 128 | self._arena.render_object_to_arena(self.position, self._symbol) 129 | 130 | def collision(self, game_object): 131 | return self.position == game_object.position 132 | 133 | def collision_with_tail(self): 134 | return self.position in self._tail 135 | 136 | def has_active_portal(self): 137 | return self._portal_entry is not None or self._portal_exit is not None 138 | 139 | def create_portal(self, from_pos, to_pos, crystal): 140 | self._portal_entry = from_pos 141 | self._portal_exit = to_pos 142 | # Render portals 143 | self._arena.render_object_to_arena(from_pos, self._portal_symbol) 144 | self._arena.render_object_to_arena(to_pos, self._portal_symbol) 145 | 146 | if to_pos == crystal.position: 147 | self.collect_crystals(crystal) 148 | 149 | 150 | def check_portal_clear(self): 151 | # Check if all tail segments have passed through the portal 152 | if self._portal_entry and self._portal_exit: 153 | # Portal is clear when no tail segments are at the entry position 154 | # and wizard is not at the exit position 155 | if self._portal_entry not in self._tail and self.position != self._portal_exit: 156 | # Clean up portals 157 | self._arena.clean_up_wizard(self._portal_entry) 158 | self._arena.clean_up_wizard(self._portal_exit) 159 | self._portal_entry = None 160 | self._portal_exit = None 161 | 162 | 163 | class Crystal: 164 | def __init__(self, x, y, arena): 165 | self._symbol = "♦" 166 | self._x = x 167 | self._y = y 168 | self._arena = arena 169 | 170 | self.render_crystal_to_arena() 171 | 172 | @property 173 | def position(self): 174 | """The position property.""" 175 | return (self._x, self._y) 176 | 177 | @position.setter 178 | def position(self, position): 179 | self._x, self._y = position 180 | self.render_crystal_to_arena() 181 | 182 | def spawn_depreciated(self, wizard: Wizard): 183 | wx, wy = wizard.position 184 | 185 | spawned = False 186 | while not spawned: 187 | # determines where the crystal should be placed on the arena 188 | x = randrange(0, self._arena._column_size, 2) 189 | y = randrange(0, self._arena._row_size) 190 | 191 | # Check if position is far enough from wizard AND not on any tail segment 192 | if abs(x - wx) > 2 and abs(y - wy) > 2: 193 | # Also check that it's not on any tail segment 194 | if (x, y) not in wizard._tail: 195 | self.position = (x, y) 196 | spawned = True 197 | 198 | def spawn(self, wizard: Wizard): 199 | spawn_points = [] 200 | 201 | for y in range(self._arena._row_size): 202 | for x in range(0, self._arena._column_size, 2): 203 | if self._arena.arena[y][x] == ".": 204 | spawn_points.append((x, y)) 205 | 206 | self.position = random.choice(spawn_points) 207 | 208 | def render_crystal_to_arena(self): 209 | self._arena.render_object_to_arena(self.position, self._symbol) 210 | 211 | 212 | def test(): 213 | arena = Arena(size=15) 214 | wizard = Wizard(0, 4, arena) 215 | crystal = Crystal(6, 7, arena) 216 | 217 | print(arena) 218 | 219 | 220 | if __name__ == "__main__": 221 | test() 222 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Main entry point for the wizard game 4 | """ 5 | 6 | from blessed import Terminal 7 | 8 | from game import Arena, Crystal, Wizard 9 | from menu import Menu 10 | from game_over import GameOverScreen 11 | from database import init_database 12 | 13 | def main(): 14 | # Initialize database 15 | init_database() 16 | 17 | # Main application loop 18 | while True: 19 | # Show menu 20 | menu = Menu() 21 | if not menu.display(): 22 | print("Thanks for playing!") 23 | return 24 | 25 | # Initialize terminal and start game 26 | play_game() 27 | 28 | def play_game(): 29 | # Initialize terminal 30 | term = Terminal() 31 | 32 | x,y=0,0 33 | 34 | # Create the game objects 35 | arena = Arena(size=10) 36 | wizard = Wizard(x, y, arena) 37 | crystal = Crystal(4, 4, arena) 38 | 39 | # Number buffer for #G command 40 | number_buffer = "" 41 | # Command mode buffer 42 | command_buffer = "" 43 | command_mode = False 44 | 45 | with term.fullscreen(), term.cbreak(), term.hidden_cursor(): 46 | 47 | loop = True 48 | game_lost = False 49 | while loop: 50 | 51 | # Clear the screen 52 | print('\033[2J\033[3J\033[H') 53 | 54 | # Show Crystals collected as score 55 | print(f"Score: {wizard.crystals}") 56 | # print(f"Available: {arena._rendered_objects_percentage}%\n") # For debugging purposes 57 | 58 | # Show the arena 59 | print(arena) 60 | 61 | # Display instructions 62 | print(f"Press 'h/j/k/l' to move left/down/up/right") 63 | print(f"Press '0/$' to teleport leftmost/rightmost") 64 | print(f"Press '#G' to teleport to row # (e.g., 5G for row 5)") 65 | print(f"Press ':q!' to quit") 66 | if number_buffer: 67 | print(f"Number buffer: {number_buffer}") 68 | if command_mode: 69 | print(f":{command_buffer}") 70 | 71 | # Wait for input 72 | key = term.inkey() 73 | 74 | # Handle command mode 75 | if key == ':' and not command_mode: 76 | command_mode = True 77 | command_buffer = "" 78 | elif command_mode: 79 | if key.code == term.KEY_ENTER or key == '\r' or key == '\n': 80 | # Execute command 81 | if command_buffer == "q!": 82 | loop = False 83 | # Clear command mode 84 | command_mode = False 85 | command_buffer = "" 86 | elif key.code == term.KEY_ESCAPE or key == '\x1b': 87 | # Exit command mode 88 | command_mode = False 89 | command_buffer = "" 90 | elif key.code == term.KEY_BACKSPACE or key == '\x7f' or key == '\b': 91 | # Handle backspace 92 | if command_buffer: 93 | command_buffer = command_buffer[:-1] 94 | elif key and not key.is_sequence: 95 | # Add character to command buffer 96 | command_buffer += str(key) 97 | 98 | # Skip all game controls if in command mode 99 | elif not command_mode: 100 | # Movement vectors as tuples 101 | movements = { 102 | 'h': (-2, 0), # Left 103 | 'l': (2, 0), # Right 104 | 'k': (0, -1), #Up 105 | 'j': (0, 1) #Down 106 | } 107 | 108 | # Movement Handling 109 | if key.lower() in movements: 110 | dx, dy = movements[key.lower()] 111 | current_x, current_y = wizard.position 112 | new_x = current_x + dx 113 | new_y = current_y + dy 114 | 115 | # Check boundaries 116 | if ( 0 <= new_x <= (arena._size -1) * 2 and 117 | 0 <= new_y <= arena._size -1): 118 | # Check if trying to move into the immediate tail segment 119 | if wizard._tail and (new_x, new_y) == wizard._tail[0]: 120 | pass # Don't allow this move 121 | else: 122 | wizard.position = (new_x, new_y) 123 | 124 | # Check for tail collision 125 | if wizard.collision_with_tail(): 126 | game_lost = True 127 | loop = False 128 | 129 | elif key == '0' and not number_buffer: # Go to start of current row only if buffer is empty 130 | if not wizard.has_active_portal(): 131 | old_pos = wizard.position 132 | _, current_y = wizard.position 133 | new_pos = (0, current_y) 134 | 135 | # Only teleport if not moving to same position 136 | if old_pos != new_pos: 137 | wizard.create_portal(old_pos, new_pos, crystal) 138 | wizard.position = new_pos 139 | 140 | # Check for tail collision 141 | if wizard.collision_with_tail(): 142 | game_lost = True 143 | loop = False 144 | 145 | elif key == '$': # Go to end of current row 146 | if not wizard.has_active_portal(): 147 | old_pos = wizard.position 148 | _, current_y = wizard.position 149 | new_pos = ((arena._size -1) * 2, current_y) 150 | number_buffer = "" # Clear buffer 151 | 152 | # Only teleport if not moving to same position 153 | if old_pos != new_pos: 154 | wizard.create_portal(old_pos, new_pos, crystal) 155 | wizard.position = new_pos 156 | 157 | # Check for tail collision 158 | if wizard.collision_with_tail(): 159 | game_lost = True 160 | loop = False 161 | 162 | # collision detection 163 | if wizard.collision(crystal): 164 | # call the crystal re-render method 165 | wizard.collect_crystals(crystal) 166 | 167 | # Handle number input (0-9) 168 | elif key.isdigit() and (key != '0' or number_buffer): # Allow 0 if buffer has content 169 | number_buffer += key 170 | 171 | # Handle G command for row teleportation 172 | elif key == 'G' and number_buffer: 173 | if not wizard.has_active_portal(): 174 | target_row = int(number_buffer) - 1 # Adjusted for zero index 175 | old_pos = wizard.position 176 | current_x, _ = wizard.position 177 | new_pos = (current_x, target_row) 178 | 179 | # Check if target row is valid and not same position 180 | if 0 <= target_row <= arena._size -1 and old_pos != new_pos: 181 | wizard.create_portal(old_pos, new_pos, crystal) 182 | wizard.position = new_pos 183 | 184 | # Check for tail collision 185 | if wizard.collision_with_tail(): 186 | game_lost = True 187 | loop = False 188 | 189 | number_buffer = "" # Clearing buffer after use of G command 190 | 191 | # Clear buffer on other keys 192 | elif key and not key.isdigit(): 193 | number_buffer = "" 194 | 195 | # Check if portal should close (after all movements) 196 | wizard.check_portal_clear() 197 | 198 | # Clear screen on exit 199 | print(term.clear) 200 | 201 | # Handle different exit scenarios 202 | if game_lost: 203 | # Show game over screen with initials input 204 | game_over_screen = GameOverScreen() 205 | game_over_screen.show(wizard.crystals) 206 | # After game over, return to menu 207 | else: 208 | print("The wizard has left the building") 209 | # Brief pause before returning to menu 210 | import time 211 | time.sleep(1) 212 | 213 | if __name__ == "__main__": 214 | main() 215 | --------------------------------------------------------------------------------