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.
- gh_space_shooter/__init__.py +19 -0
- gh_space_shooter/cli.py +177 -0
- gh_space_shooter/console_printer.py +65 -0
- gh_space_shooter/constants.py +11 -0
- gh_space_shooter/game/__init__.py +27 -0
- gh_space_shooter/game/animator.py +101 -0
- gh_space_shooter/game/drawables/__init__.py +17 -0
- gh_space_shooter/game/drawables/bullet.py +83 -0
- gh_space_shooter/game/drawables/drawable.py +29 -0
- gh_space_shooter/game/drawables/enemy.py +58 -0
- gh_space_shooter/game/drawables/explosion.py +72 -0
- gh_space_shooter/game/drawables/ship.py +107 -0
- gh_space_shooter/game/drawables/starfield.py +65 -0
- gh_space_shooter/game/game_state.py +80 -0
- gh_space_shooter/game/render_context.py +56 -0
- gh_space_shooter/game/renderer.py +44 -0
- gh_space_shooter/game/strategies/__init__.py +14 -0
- gh_space_shooter/game/strategies/base_strategy.py +43 -0
- gh_space_shooter/game/strategies/column_strategy.py +46 -0
- gh_space_shooter/game/strategies/random_strategy.py +61 -0
- gh_space_shooter/game/strategies/row_strategy.py +41 -0
- gh_space_shooter/github_client.py +172 -0
- gh_space_shooter/py.typed +0 -0
- gh_space_shooter-0.0.1.dist-info/METADATA +141 -0
- gh_space_shooter-0.0.1.dist-info/RECORD +28 -0
- gh_space_shooter-0.0.1.dist-info/WHEEL +4 -0
- gh_space_shooter-0.0.1.dist-info/entry_points.txt +2 -0
- gh_space_shooter-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|
gh_space_shooter/cli.py
ADDED
|
@@ -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
|
+
)
|