gh-space-shooter 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ """GitHub contribution graph gamification tool."""
2
+
3
+ from .github_client import (
4
+ ContributionData,
5
+ ContributionDay,
6
+ ContributionWeek,
7
+ GitHubAPIError,
8
+ GitHubClient,
9
+ )
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = [
14
+ "GitHubClient",
15
+ "GitHubAPIError",
16
+ "ContributionData",
17
+ "ContributionDay",
18
+ "ContributionWeek",
19
+ ]
@@ -0,0 +1,177 @@
1
+ """CLI interface for gh-space-shooter."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+
7
+ import typer
8
+ from dotenv import load_dotenv
9
+ from rich.console import Console
10
+
11
+ from gh_space_shooter.game.strategies.base_strategy import BaseStrategy
12
+
13
+ from .console_printer import ContributionConsolePrinter
14
+ from .game import Animator, ColumnStrategy, RandomStrategy, RowStrategy
15
+ from .github_client import ContributionData, GitHubAPIError, GitHubClient
16
+
17
+ # Load environment variables from .env file
18
+ load_dotenv()
19
+
20
+ console = Console()
21
+ err_console = Console(stderr=True)
22
+
23
+
24
+ class CLIError(Exception):
25
+ """Base exception for CLI errors with user-friendly messages."""
26
+ pass
27
+
28
+
29
+ def main(
30
+ username: str = typer.Argument(None, help="GitHub username to fetch data for"),
31
+ raw_input: str = typer.Option(
32
+ None,
33
+ "--raw-input",
34
+ "--raw-in",
35
+ "-ri",
36
+ help="Load contribution data from JSON file (skips GitHub API call)",
37
+ ),
38
+ raw_output: str = typer.Option(
39
+ None,
40
+ "--raw-output",
41
+ "--raw-out",
42
+ "-ro",
43
+ help="Save contribution data to JSON file",
44
+ ),
45
+ out: str = typer.Option(
46
+ None,
47
+ "--output",
48
+ "-out",
49
+ "-o",
50
+ help="Generate animated GIF visualization",
51
+ ),
52
+ strategy: str = typer.Option(
53
+ "random",
54
+ "--strategy",
55
+ "-s",
56
+ help="Strategy for clearing enemies (column, row, random)",
57
+ ),
58
+ ) -> None:
59
+ """
60
+ Fetch or load GitHub contribution graph data and display it.
61
+
62
+ You can either fetch fresh data from GitHub or load from a previously saved file.
63
+ This is useful for saving API rate limits.
64
+
65
+ Examples:
66
+ # Fetch from GitHub and save
67
+ gh-space-shooter czl9707 --raw-output data.json
68
+
69
+ # Load from saved file
70
+ gh-space-shooter --raw-input data.json
71
+ """
72
+ try:
73
+ if not username:
74
+ raise CLIError("Username is required when not using --raw-input")
75
+ if not out:
76
+ out = f"{username}-gh-space-shooter.gif"
77
+ # Load data from file or GitHub
78
+ if raw_input:
79
+ data = _load_data_from_file(raw_input)
80
+ else:
81
+ data = _load_data_from_github(username)
82
+
83
+ # Display the data
84
+ printer = ContributionConsolePrinter()
85
+ printer.display_stats(data)
86
+ printer.display_contribution_graph(data)
87
+
88
+ # Save to file if requested
89
+ if raw_output:
90
+ _save_data_to_file(data, raw_output)
91
+
92
+ # Generate GIF if requested
93
+ _generate_gif(data, out, strategy)
94
+
95
+ except CLIError as e:
96
+ err_console.print(f"[bold red]Error:[/bold red] {e}")
97
+ sys.exit(1)
98
+
99
+ except Exception as e:
100
+ err_console.print(f"[bold red]Unexpected error:[/bold red] {e}")
101
+ sys.exit(1)
102
+
103
+
104
+ def _load_env_and_validate() -> str:
105
+ """Load environment variables and validate required settings. Returns token."""
106
+ token = os.getenv("GH_TOKEN")
107
+ if not token:
108
+ raise CLIError(
109
+ "GitHub token not found. "
110
+ "Set your GitHub token in the GH_TOKEN environment variable."
111
+ )
112
+ return token
113
+
114
+
115
+ def _load_data_from_file(file_path: str) -> ContributionData:
116
+ """Load contribution data from a JSON file."""
117
+ console.print(f"[bold blue]Loading data from {file_path}...[/bold blue]")
118
+ try:
119
+ with open(file_path, "r") as f:
120
+ return json.load(f)
121
+ except FileNotFoundError:
122
+ raise CLIError(f"File '{file_path}' not found")
123
+ except json.JSONDecodeError as e:
124
+ raise CLIError(f"Invalid JSON in '{file_path}': {e}")
125
+
126
+
127
+ def _load_data_from_github(username: str) -> ContributionData:
128
+ """Fetch contribution data from GitHub API."""
129
+ token = _load_env_and_validate()
130
+
131
+ console.print(f"[bold blue]Fetching contribution data for {username}...[/bold blue]")
132
+ try:
133
+ with GitHubClient(token) as client:
134
+ return client.get_contribution_graph(username)
135
+ except GitHubAPIError as e:
136
+ raise CLIError(f"GitHub API error: {e}")
137
+
138
+
139
+ def _save_data_to_file(data: ContributionData, file_path: str) -> None:
140
+ """Save contribution data to a JSON file."""
141
+ try:
142
+ with open(file_path, "w") as f:
143
+ json.dump(data, f, indent=2)
144
+ console.print(f"\n[green]✓[/green] Data saved to {file_path}")
145
+ except IOError as e:
146
+ raise CLIError(f"Failed to save file '{file_path}': {e}")
147
+
148
+
149
+ def _generate_gif(data: ContributionData, file_path: str, strategy_name: str) -> None:
150
+ """Generate animated GIF visualization."""
151
+ console.print("\n[bold blue]Generating GIF animation...[/bold blue]")
152
+
153
+ if strategy_name == "column":
154
+ strategy: BaseStrategy = ColumnStrategy()
155
+ elif strategy_name == "row":
156
+ strategy = RowStrategy()
157
+ elif strategy_name == "random":
158
+ strategy = RandomStrategy()
159
+ else:
160
+ raise CLIError(
161
+ f"Unknown strategy '{strategy_name}'. Available: column, row, random"
162
+ )
163
+
164
+ # Create animator and generate GIF
165
+ try:
166
+ animator = Animator(data, strategy)
167
+ animator.generate_gif(file_path)
168
+ console.print(f"[green]✓[/green] GIF saved to {file_path}")
169
+ except Exception as e:
170
+ raise CLIError(f"Failed to generate GIF: {e}")
171
+
172
+
173
+ app = typer.Typer()
174
+ app.command()(main)
175
+
176
+ if __name__ == "__main__":
177
+ app()
@@ -0,0 +1,65 @@
1
+ """Console output formatting and display functions."""
2
+
3
+ from rich.console import Console
4
+ from rich.text import Text
5
+
6
+ from .github_client import ContributionData
7
+
8
+ console = Console()
9
+
10
+ class ContributionConsolePrinter:
11
+ def display_stats(self, data: ContributionData) -> None:
12
+ """Display contribution statistics in a one-liner."""
13
+ # Get date range
14
+ all_days = [day for week in data["weeks"] for day in week["days"]]
15
+ if all_days:
16
+ start_date = all_days[0]["date"]
17
+ end_date = all_days[-1]["date"]
18
+
19
+ console.print(
20
+ f"\n[bold green]✓[/bold green] @{data['username']}: "
21
+ f"{data['total_contributions']} contributions from {start_date} to {end_date}, "
22
+ f"{len(data['weeks'])} weeks in total.\n"
23
+ )
24
+
25
+ def display_contribution_graph(self, data: ContributionData) -> None:
26
+ """Display a GitHub-style contribution graph."""
27
+ weeks = data["weeks"]
28
+ day_labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
29
+
30
+ console.print("[bold]Contribution Graph:[/bold]\n")
31
+
32
+ for day_idx in range(7): # 0=Sunday, 6=Saturday
33
+ console.print(f" {day_labels[day_idx]} ", end="")
34
+
35
+ # Print colored blocks for this day across all weeks
36
+ for week in weeks:
37
+
38
+ if day_idx < len(week["days"]):
39
+ day = week["days"][day_idx]
40
+ level = day["level"]
41
+ else:
42
+ level = 0
43
+ self._print_block(level)
44
+
45
+ console.print() # New line after each day row
46
+
47
+ # Print legend
48
+ console.print("\n Less ", end="")
49
+ for level in range(5):
50
+ self._print_block(level)
51
+ console.print(" ", end="")
52
+ console.print("More")
53
+
54
+ COLOR_MAP = {
55
+ 0: "", # Transparent
56
+ 1: "on rgb(0,109,50)", # Light green
57
+ 2: "on rgb(38,166,65)", # Medium green
58
+ 3: "on rgb(57,211,83)", # Bright green
59
+ 4: "on rgb(87,242,135)", # Very bright green
60
+ }
61
+
62
+ def _print_block(self, level: int) -> None:
63
+ """Print a colored block based on contribution level."""
64
+ text = Text(" ", style=self.COLOR_MAP.get(level, ""))
65
+ console.print(text, end="")
@@ -0,0 +1,11 @@
1
+ """Global constants for the application."""
2
+
3
+ # GitHub contribution graph dimensions
4
+ NUM_WEEKS = 52 # Number of weeks in contribution graph
5
+ NUM_DAYS = 7 # Number of days in a week (Sun-Sat)
6
+ SHIP_POSITION_Y = NUM_DAYS + 3 # Ship is positioned just below the grid
7
+
8
+ SHIP_SPEED = 0.25 # Cells per frame the ship moves
9
+ BULLET_SPEED = 0.15 # Cells per frame the bullet moves
10
+ FRAME_DURATION_MS = 20 # Duration of each frame in milliseconds
11
+ SHIP_SHOOT_COOLDOWN_FRAMES = 10 # Frames between ship shots
@@ -0,0 +1,27 @@
1
+ """Game animation module for GitHub contribution visualization."""
2
+
3
+ from .animator import Animator
4
+ from .drawables import Bullet, Drawable, Enemy, Explosion, Ship, Starfield
5
+ from .game_state import GameState
6
+ from .renderer import Renderer
7
+ from .strategies.base_strategy import Action, BaseStrategy
8
+ from .strategies.column_strategy import ColumnStrategy
9
+ from .strategies.random_strategy import RandomStrategy
10
+ from .strategies.row_strategy import RowStrategy
11
+
12
+ __all__ = [
13
+ "Animator",
14
+ "Bullet",
15
+ "Drawable",
16
+ "Enemy",
17
+ "Explosion",
18
+ "GameState",
19
+ "Renderer",
20
+ "Ship",
21
+ "Starfield",
22
+ "BaseStrategy",
23
+ "Action",
24
+ "ColumnStrategy",
25
+ "RowStrategy",
26
+ "RandomStrategy",
27
+ ]
@@ -0,0 +1,101 @@
1
+ """Animator for generating GIF animations from game strategies."""
2
+
3
+ from typing import Iterator
4
+
5
+ from PIL import Image
6
+
7
+
8
+ from ..constants import FRAME_DURATION_MS
9
+ from ..github_client import ContributionData
10
+ from .game_state import GameState
11
+ from .renderer import Renderer
12
+ from .strategies.base_strategy import BaseStrategy
13
+ from .render_context import RenderContext
14
+
15
+
16
+ class Animator:
17
+ """Generates animated GIFs from game strategies."""
18
+
19
+ def __init__(
20
+ self,
21
+ contribution_data: ContributionData,
22
+ strategy: BaseStrategy,
23
+ frame_duration: int = FRAME_DURATION_MS,
24
+ ):
25
+ """
26
+ Initialize animator.
27
+
28
+ Args:
29
+ contribution_data: The GitHub contribution data
30
+ strategy: The strategy to use for clearing enemies
31
+ frame_duration: Duration of each frame in milliseconds
32
+ """
33
+ self.contribution_data = contribution_data
34
+ self.strategy = strategy
35
+ self.frame_duration = frame_duration
36
+
37
+ def generate_gif(self, output_path: str) -> None:
38
+ """
39
+ Generate animated GIF and save to file.
40
+
41
+ Args:
42
+ output_path: Path where GIF should be saved
43
+ """
44
+ # Initialize game state
45
+ game_state = GameState(self.contribution_data)
46
+ renderer = Renderer(game_state, RenderContext.darkmode())
47
+
48
+ frames = self._generate_frames(game_state, renderer)
49
+
50
+ # Save as GIF
51
+ if frames:
52
+ next(frames).save(
53
+ output_path,
54
+ save_all=True,
55
+ append_images=list(frames),
56
+ duration=self.frame_duration,
57
+ loop=0, # Loop forever
58
+ optimize=False,
59
+ )
60
+
61
+ def _generate_frames(
62
+ self, game_state: GameState, renderer: Renderer
63
+ ) -> Iterator[Image.Image]:
64
+ """
65
+ Generate all animation frames.
66
+
67
+ Args:
68
+ game_state: The game state
69
+ renderer: The renderer
70
+
71
+ Returns:
72
+ List of PIL Images representing animation frames
73
+ """
74
+
75
+ # Add initial frame showing starting state
76
+ yield renderer.render_frame()
77
+
78
+ # Process each action from the strategy
79
+ for action in self.strategy.generate_actions(game_state):
80
+ game_state.ship.move_to(action.x)
81
+ while game_state.can_take_action() is False:
82
+ game_state.animate()
83
+ yield renderer.render_frame()
84
+
85
+ if action.shoot:
86
+ game_state.shoot()
87
+ game_state.animate()
88
+ yield renderer.render_frame()
89
+
90
+ force_kill_countdown = 100
91
+ # Add final frames showing completion
92
+ while not game_state.is_complete():
93
+ game_state.animate()
94
+ yield renderer.render_frame()
95
+
96
+ force_kill_countdown -= 1
97
+ if force_kill_countdown <= 0:
98
+ break
99
+
100
+ for _ in range(5):
101
+ yield renderer.render_frame()
@@ -0,0 +1,17 @@
1
+ """Drawable game objects."""
2
+
3
+ from .bullet import Bullet
4
+ from .drawable import Drawable
5
+ from .enemy import Enemy
6
+ from .explosion import Explosion
7
+ from .ship import Ship
8
+ from .starfield import Starfield
9
+
10
+ __all__ = [
11
+ "Bullet",
12
+ "Drawable",
13
+ "Enemy",
14
+ "Explosion",
15
+ "Ship",
16
+ "Starfield",
17
+ ]
@@ -0,0 +1,83 @@
1
+ """Bullet objects fired by the ship."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from PIL import ImageDraw
6
+
7
+ from ...constants import BULLET_SPEED, SHIP_POSITION_Y
8
+ from .drawable import Drawable
9
+ from .explosion import Explosion
10
+
11
+ if TYPE_CHECKING:
12
+ from .enemy import Enemy
13
+ from ..game_state import GameState
14
+ from ..render_context import RenderContext
15
+
16
+
17
+ class Bullet(Drawable):
18
+ """Represents a bullet fired by the ship."""
19
+
20
+ def __init__(self, x: int, game_state: "GameState"):
21
+ """
22
+ Initialize a bullet at ship's firing position.
23
+
24
+ Args:
25
+ x: Week position where bullet is fired (0-51)
26
+ game_state: Reference to game state for collision detection and self-removal
27
+ """
28
+ self.x = x
29
+ self.y: float = SHIP_POSITION_Y - 1
30
+ self.game_state = game_state
31
+
32
+
33
+ def _check_collision(self) -> "Enemy | None":
34
+ """Check if bullet has hit an enemy at its current position."""
35
+ for enemy in self.game_state.enemies:
36
+ if enemy.x == self.x and enemy.y >= self.y:
37
+ return enemy
38
+ return None
39
+
40
+ def animate(self) -> None:
41
+ """Update bullet position, check for collisions, and remove on hit."""
42
+ self.y -= BULLET_SPEED
43
+ hit_enemy = self._check_collision()
44
+ if hit_enemy:
45
+ explosion = Explosion(self.x, self.y, "small", self.game_state)
46
+ self.game_state.explosions.append(explosion)
47
+ hit_enemy.take_damage()
48
+ self.game_state.bullets.remove(self)
49
+ if self.y < -10: # magic number to remove off-screen bullets
50
+ self.game_state.bullets.remove(self)
51
+
52
+ def draw(self, draw: ImageDraw.ImageDraw, context: "RenderContext") -> None:
53
+ """Draw the bullet with trailing tail effect."""
54
+
55
+ trail_num = 3
56
+ for i in range(trail_num):
57
+ trail_y = self.y + (i + 1) * BULLET_SPEED
58
+ fade_factor = (i + 1) / trail_num / 2
59
+ self._draw_bullet(draw, context, (self.x, trail_y), fade_factor=fade_factor)
60
+
61
+ self._draw_bullet(draw, context, (self.x, self.y), fade_factor=0.3, offset=.6)
62
+ self._draw_bullet(draw, context, (self.x, self.y), fade_factor=0.4, offset=.4)
63
+ self._draw_bullet(draw, context, (self.x, self.y), fade_factor=0.5, offset=.2)
64
+ self._draw_bullet(draw, context, (self.x, self.y))
65
+
66
+ def _draw_bullet(
67
+ self,
68
+ draw: ImageDraw.ImageDraw,
69
+ context: "RenderContext",
70
+ position: tuple[float, float],
71
+ fade_factor: float = 1,
72
+ offset: float = 0
73
+ ) -> None:
74
+ x, y = context.get_cell_position(position[0], position[1])
75
+ x += context.cell_size // 2
76
+ y += context.cell_size // 2
77
+
78
+ r_x = 0.5 + offset
79
+ r_y = 4 + offset
80
+ draw.rectangle(
81
+ [x - r_x, y - r_y, x + r_x, y + r_y],
82
+ fill=(*context.bullet_color, int(fade_factor * 255)),
83
+ )
@@ -0,0 +1,29 @@
1
+ """Base Drawable interface for game objects."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import TYPE_CHECKING
5
+
6
+ from PIL import ImageDraw
7
+
8
+ if TYPE_CHECKING:
9
+ from .render_context import RenderContext
10
+
11
+
12
+ class Drawable(ABC):
13
+ """Interface for objects that can be animated and drawn."""
14
+
15
+ @abstractmethod
16
+ def animate(self) -> None:
17
+ """Update the object's state for the next animation frame."""
18
+ pass
19
+
20
+ @abstractmethod
21
+ def draw(self, draw: ImageDraw.ImageDraw, context: "RenderContext") -> None:
22
+ """
23
+ Draw the object on the image.
24
+
25
+ Args:
26
+ draw: PIL ImageDraw object
27
+ context: Rendering context with helper functions and constants
28
+ """
29
+ pass
@@ -0,0 +1,58 @@
1
+ """Enemy objects representing contribution graph data."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from PIL import ImageDraw
6
+
7
+ from .drawable import Drawable
8
+ from .explosion import Explosion
9
+
10
+ if TYPE_CHECKING:
11
+ from ..game_state import GameState
12
+ from ..render_context import RenderContext
13
+
14
+
15
+ class Enemy(Drawable):
16
+ """Represents an enemy at a specific position."""
17
+
18
+ def __init__(self, x: int, y: int, health: int, game_state: "GameState"):
19
+ """
20
+ Initialize an enemy.
21
+
22
+ Args:
23
+ x: Week position in contribution grid (0-51)
24
+ y: Day position in contribution grid (0-6, Sun-Sat)
25
+ health: Initial health/lives (1-4)
26
+ game_state: Reference to game state for self-removal when destroyed
27
+ """
28
+ self.x = x
29
+ self.y = y
30
+ self.health = health
31
+ self.game_state = game_state
32
+
33
+ def take_damage(self) -> None:
34
+ """
35
+ Enemy takes 1 damage and removes itself from game if destroyed.
36
+ Creates a large explosion when destroyed.
37
+ """
38
+ self.health -= 1
39
+ if self.health <= 0:
40
+ # Create large explosion with green color (enemy color)
41
+ explosion = Explosion(self.x, self.y, "large", self.game_state)
42
+ self.game_state.explosions.append(explosion)
43
+ self.game_state.enemies.remove(self)
44
+
45
+ def animate(self) -> None:
46
+ """Update enemy state for next frame (enemies don't animate currently)."""
47
+ pass
48
+
49
+ def draw(self, draw: ImageDraw.ImageDraw, context: "RenderContext") -> None:
50
+ """Draw the enemy at its position with rounded corners."""
51
+ x, y = context.get_cell_position(self.x, self.y)
52
+ color = context.enemy_colors.get(self.health, context.enemy_colors[1])
53
+
54
+ draw.rounded_rectangle(
55
+ [x, y, x + context.cell_size, y + context.cell_size],
56
+ radius=2,
57
+ fill=color,
58
+ )
@@ -0,0 +1,72 @@
1
+ """Explosion effects for bullet hits and enemy destruction."""
2
+
3
+ import math
4
+ import random
5
+ from typing import TYPE_CHECKING, Literal
6
+
7
+ from PIL import ImageDraw
8
+
9
+ from .drawable import Drawable
10
+
11
+ if TYPE_CHECKING:
12
+ from ..game_state import GameState
13
+ from ..render_context import RenderContext
14
+
15
+
16
+ class Explosion(Drawable):
17
+ """Particle explosion effect that expands and fades out."""
18
+
19
+ def __init__(self, x: float, y: float, size: Literal["small", "large"], game_state: "GameState"):
20
+ """
21
+ Initialize an explosion.
22
+
23
+ Args:
24
+ x: X position (week, 0-51)
25
+ y: Y position (day, 0-6)
26
+ size: "small" for bullet hits, "large" for enemy destruction
27
+ game_state: Reference to game state for self-removal
28
+ """
29
+ self.x = x
30
+ self.y = y
31
+ self.game_state = game_state
32
+ self.frame = 0
33
+ self.max_frames = 6 if size == "small" else 20
34
+ self.max_radius = 10 if size == "small" else 20
35
+ self.particle_count = 4 if size == "small" else 8
36
+ # Generate random angles for each particle
37
+ self.particle_angles = [random.uniform(0, 2 * math.pi) for _ in range(self.particle_count)]
38
+
39
+ def animate(self) -> None:
40
+ """Progress the explosion animation and remove when complete."""
41
+ self.frame += 1
42
+ if self.frame >= self.max_frames:
43
+ self.game_state.explosions.remove(self)
44
+
45
+ def draw(self, draw: ImageDraw.ImageDraw, context: "RenderContext") -> None:
46
+ """Draw expanding particle explosion with fade effect."""
47
+ # Calculate animation progress (0 to 1)
48
+ progress = self.frame / self.max_frames
49
+ fade = 1 - progress # Fade out as animation progresses
50
+
51
+ # Get center position
52
+ center_x, center_y = context.get_cell_position(self.x, self.y)
53
+ center_x += context.cell_size // 2
54
+ center_y += context.cell_size // 2
55
+
56
+ # Draw expanding particles in random directions
57
+ for i in range(self.particle_count):
58
+ distance = progress * self.max_radius
59
+ angle = self.particle_angles[i]
60
+
61
+ # Particle position using random angle
62
+ px = int(center_x + distance * math.cos(angle))
63
+ py = int(center_y + distance * math.sin(angle))
64
+
65
+ # Particle size decreases as it expands
66
+ particle_size = int((1 - progress * 0.5) * 3) + 1
67
+
68
+ draw.rectangle(
69
+ [px - particle_size, py - particle_size,
70
+ px + particle_size, py + particle_size],
71
+ fill=(*context.bullet_color, int(255 * fade))
72
+ )