gh-space-shooter 0.0.3__tar.gz → 0.0.5__tar.gz

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.
Files changed (41) hide show
  1. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/.github/workflows/publish.yml +5 -4
  2. gh_space_shooter-0.0.5/CLAUDE.md +87 -0
  3. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/PKG-INFO +9 -2
  4. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/README.md +8 -1
  5. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/action.yml +6 -1
  6. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/pyproject.toml +2 -2
  7. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/cli.py +38 -6
  8. gh_space_shooter-0.0.5/src/gh_space_shooter/constants.py +30 -0
  9. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/animator.py +29 -16
  10. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/drawables/bullet.py +9 -5
  11. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/drawables/drawable.py +6 -2
  12. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/drawables/enemy.py +1 -1
  13. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/drawables/explosion.py +13 -9
  14. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/drawables/ship.py +13 -8
  15. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/drawables/starfield.py +31 -15
  16. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/game_state.py +13 -9
  17. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/renderer.py +31 -5
  18. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/tests/test_bullet_collision.py +15 -12
  19. gh_space_shooter-0.0.5/uv.lock +305 -0
  20. gh_space_shooter-0.0.3/src/gh_space_shooter/constants.py +0 -19
  21. gh_space_shooter-0.0.3/uv.lock +0 -305
  22. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/.github/dependabot.yml +0 -0
  23. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/.github/workflows/test.yml +0 -0
  24. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/.gitignore +0 -0
  25. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/.python-version +0 -0
  26. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/LICENSE +0 -0
  27. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/example.gif +0 -0
  28. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/__init__.py +0 -0
  29. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/console_printer.py +0 -0
  30. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/__init__.py +0 -0
  31. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/drawables/__init__.py +0 -0
  32. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/render_context.py +0 -0
  33. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/strategies/__init__.py +0 -0
  34. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/strategies/base_strategy.py +0 -0
  35. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/strategies/column_strategy.py +0 -0
  36. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/strategies/random_strategy.py +0 -0
  37. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/game/strategies/row_strategy.py +0 -0
  38. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/github_client.py +0 -0
  39. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/src/gh_space_shooter/py.typed +0 -0
  40. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/tests/conftest.py +0 -0
  41. {gh_space_shooter-0.0.3 → gh_space_shooter-0.0.5}/tests/test_strategies.py +0 -0
@@ -45,11 +45,12 @@ jobs:
45
45
  - name: Publish
46
46
  run: uv publish --token ${{ secrets.PYPI_API_TOKEN }}
47
47
 
48
- - name: GitHub Release v${{ steps.bump.outputs.current-version }}
48
+ - name: GitHub Release PIP v${{ steps.bump.outputs.current-version }}
49
49
  uses: ncipollo/release-action@v1
50
50
  with:
51
- name: pytest-asyncio-concurrent v${{ steps.bump.outputs.current-version }}
51
+ name: gh-space-shooter pypi v${{ steps.bump.outputs.current-version }}
52
52
  tag: v${{ steps.bump.outputs.current-version }}
53
- body: "Automated release of version v${{ steps.bump.outputs.current-version }}"
53
+ body: "Automated release of pypi version v${{ steps.bump.outputs.current-version }}"
54
54
  artifacts: dist/*
55
- token: ${{ secrets.GH_TOKEN }}
55
+ token: ${{ secrets.GH_TOKEN }}
56
+ generateReleaseNotes: true
@@ -0,0 +1,87 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Build and Development Commands
6
+
7
+ ```bash
8
+ # Install dependencies (uses uv package manager)
9
+ uv sync
10
+
11
+ # Install with dev dependencies
12
+ uv sync --extra dev
13
+
14
+ # Run the CLI
15
+ uv run gh-space-shooter <username>
16
+
17
+ # Run tests
18
+ uv run pytest tests/ -v
19
+
20
+ # Run a single test file
21
+ uv run pytest tests/test_strategies.py -v
22
+
23
+ # Run a specific test
24
+ uv run pytest tests/test_strategies.py::test_column_strategy -v
25
+ ```
26
+
27
+ ## Environment Setup
28
+
29
+ Requires a GitHub Personal Access Token with `read:user` scope:
30
+ ```bash
31
+ export GH_TOKEN=your_token_here
32
+ # Or create a .env file with GH_TOKEN=your_token
33
+ ```
34
+
35
+ ## Project Context
36
+
37
+ **Current main usage**: GitHub Action that automatically updates a game GIF in user repositories daily (see `.github/workflows/` for the action definition).
38
+
39
+ **Planned**: Wrap this into a Python webapp for on-demand GIF generation.
40
+
41
+ ## Architecture Overview
42
+
43
+ This is a CLI tool that transforms GitHub contribution graphs into animated space shooter GIFs using Pillow.
44
+
45
+ ### Core Flow
46
+
47
+ 1. **CLI (`cli.py`)** - Typer-based entry point that orchestrates the pipeline
48
+ 2. **GitHubClient (`github_client.py`)** - Fetches contribution data via GitHub GraphQL API, returns typed `ContributionData` dict
49
+ 3. **Animator (`game/animator.py`)** - Main game loop that coordinates strategy execution and frame generation
50
+ 4. **GameState (`game/game_state.py`)** - Central state container holding ship, enemies, bullets, explosions
51
+ 5. **Renderer (`game/renderer.py`)** - Converts GameState to PIL Images each frame
52
+
53
+ ### Strategy Pattern
54
+
55
+ Strategies (`game/strategies/`) define how the ship clears enemies:
56
+ - `BaseStrategy` - Abstract base defining `generate_actions(game_state) -> Iterator[Action]`
57
+ - `ColumnStrategy` - Clears enemies column by column (left to right)
58
+ - `RowStrategy` - Clears enemies row by row (top to bottom)
59
+ - `RandomStrategy` - Targets enemies in random order
60
+
61
+ Strategies yield `Action(x, shoot)` objects. The Animator processes these: moving the ship to position `x`, waiting for movement/cooldown to complete, then shooting if `shoot=True`.
62
+
63
+ ### Drawable System
64
+
65
+ All game objects inherit from `Drawable` (`game/drawables/drawable.py`):
66
+ - `animate(delta_time)` - Update state (position, cooldowns, particles)
67
+ - `draw(draw, context)` - Render to PIL ImageDraw
68
+
69
+ Drawables: `Ship`, `Enemy`, `Bullet`, `Explosion`, `Starfield`
70
+
71
+ The `RenderContext` (`game/render_context.py`) holds theming (colors, cell sizes, padding) and coordinate conversion helpers.
72
+
73
+ ### Animation Loop
74
+
75
+ In `Animator._generate_frames()`:
76
+ 1. Strategy yields next action
77
+ 2. Ship moves to target x position (animate frames until arrived)
78
+ 3. Ship shoots if action.shoot (animate frames for bullet travel + explosions)
79
+ 4. Repeat until all enemies destroyed
80
+
81
+ Frame rate is configurable (default 40 FPS). All speeds use delta_time for frame-rate independence.
82
+
83
+ ### Key Constants (`constants.py`)
84
+
85
+ - `NUM_WEEKS = 52` - Contribution graph width
86
+ - `NUM_DAYS = 7` - Contribution graph height
87
+ - Speeds are in cells/second, durations in seconds
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gh-space-shooter
3
- Version: 0.0.3
3
+ Version: 0.0.5
4
4
  Summary: A CLI tool that visualizes GitHub contribution graphs as gamified GIFs
5
5
  Author-email: zane <czl970721@gmail.com>
6
6
  License-File: LICENSE
@@ -60,6 +60,7 @@ Then display it in your README:
60
60
  - `username` (optional): Username to generate game for (defaults to repo owner)
61
61
  - `output-path` (optional): Where to save the GIF (default: `gh-space-shooter.gif`)
62
62
  - `strategy` (optional): Attack pattern - `column`, `row`, or `random` (default: `random`)
63
+ - `fps` (optional): Frames per second for the animation (default: `40`)
63
64
  - `commit-message` (optional): Commit message for the update
64
65
 
65
66
  ### From PyPI
@@ -121,9 +122,15 @@ gh-space-shooter torvalds --output my-epic-game.gif
121
122
  gh-space-shooter torvalds -o my-game.gif
122
123
 
123
124
  # Choose enemy attack strategy
124
- gh-space-shooter torvalds --strategy column # Enemies attack in columns
125
125
  gh-space-shooter torvalds --strategy row # Enemies attack in rows
126
126
  gh-space-shooter torvalds -s random # Random chaos (default)
127
+
128
+ # Adjust animation frame rate
129
+ gh-space-shooter torvalds --fps 25 # Lower Frame rate, Smaller file size
130
+ gh-space-shooter torvalds --fps 40 # Default Frame rate, Larger file size
131
+
132
+ # Stop the animation earlier
133
+ gh-space-shooter torvalds --max-frame 200 # Stop after 200 frames
127
134
  ```
128
135
 
129
136
  This creates an animated GIF showing:
@@ -44,6 +44,7 @@ Then display it in your README:
44
44
  - `username` (optional): Username to generate game for (defaults to repo owner)
45
45
  - `output-path` (optional): Where to save the GIF (default: `gh-space-shooter.gif`)
46
46
  - `strategy` (optional): Attack pattern - `column`, `row`, or `random` (default: `random`)
47
+ - `fps` (optional): Frames per second for the animation (default: `40`)
47
48
  - `commit-message` (optional): Commit message for the update
48
49
 
49
50
  ### From PyPI
@@ -105,9 +106,15 @@ gh-space-shooter torvalds --output my-epic-game.gif
105
106
  gh-space-shooter torvalds -o my-game.gif
106
107
 
107
108
  # Choose enemy attack strategy
108
- gh-space-shooter torvalds --strategy column # Enemies attack in columns
109
109
  gh-space-shooter torvalds --strategy row # Enemies attack in rows
110
110
  gh-space-shooter torvalds -s random # Random chaos (default)
111
+
112
+ # Adjust animation frame rate
113
+ gh-space-shooter torvalds --fps 25 # Lower Frame rate, Smaller file size
114
+ gh-space-shooter torvalds --fps 40 # Default Frame rate, Larger file size
115
+
116
+ # Stop the animation earlier
117
+ gh-space-shooter torvalds --max-frame 200 # Stop after 200 frames
111
118
  ```
112
119
 
113
120
  This creates an animated GIF showing:
@@ -22,6 +22,10 @@ inputs:
22
22
  description: 'Enemy attack strategy (column, row, or random)'
23
23
  required: false
24
24
  default: 'random'
25
+ fps:
26
+ description: 'Frames per second for the animation (default: 40)'
27
+ required: false
28
+ default: '40'
25
29
  commit-message:
26
30
  description: 'Commit message for the GIF update'
27
31
  required: false
@@ -54,7 +58,8 @@ runs:
54
58
  run: |
55
59
  gh-space-shooter ${{ inputs.username }} \
56
60
  --output ${{ inputs.output-path }} \
57
- --strategy ${{ inputs.strategy }}
61
+ --strategy ${{ inputs.strategy }} \
62
+ --fps ${{ inputs.fps }}
58
63
 
59
64
  - name: Commit and push GIF
60
65
  shell: bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gh-space-shooter"
3
- version = "0.0.3"
3
+ version = "0.0.5"
4
4
  description = "A CLI tool that visualizes GitHub contribution graphs as gamified GIFs"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -29,7 +29,7 @@ build-backend = "hatchling.build"
29
29
 
30
30
 
31
31
  [tool.bumpversion]
32
- current_version = "0.0.3"
32
+ current_version = "0.0.5"
33
33
  parse = """(?x)
34
34
  (?P<major>0|[1-9]\\d*)\\.
35
35
  (?P<minor>0|[1-9]\\d*)\\.
@@ -8,8 +8,8 @@ import typer
8
8
  from dotenv import load_dotenv
9
9
  from rich.console import Console
10
10
 
11
- from gh_space_shooter.game.strategies.base_strategy import BaseStrategy
12
-
11
+ from .constants import DEFAULT_FPS
12
+ from .game.strategies.base_strategy import BaseStrategy
13
13
  from .console_printer import ContributionConsolePrinter
14
14
  from .game import Animator, ColumnStrategy, RandomStrategy, RowStrategy
15
15
  from .github_client import ContributionData, GitHubAPIError, GitHubClient
@@ -55,6 +55,21 @@ def main(
55
55
  "-s",
56
56
  help="Strategy for clearing enemies (column, row, random)",
57
57
  ),
58
+ fps: int = typer.Option(
59
+ DEFAULT_FPS,
60
+ "--fps",
61
+ help="Frames per second for the animation",
62
+ ),
63
+ maxFrame: int | None = typer.Option(
64
+ None,
65
+ "--max-frame",
66
+ help="Maximum number of frames to generate",
67
+ ),
68
+ watermark: bool = typer.Option(
69
+ False,
70
+ "--watermark",
71
+ help="Add watermark to the GIF",
72
+ ),
58
73
  ) -> None:
59
74
  """
60
75
  Fetch or load GitHub contribution graph data and display it.
@@ -90,7 +105,7 @@ def main(
90
105
  _save_data_to_file(data, raw_output)
91
106
 
92
107
  # Generate GIF if requested
93
- _generate_gif(data, out, strategy)
108
+ _generate_gif(data, out, strategy, fps, watermark, maxFrame)
94
109
 
95
110
  except CLIError as e:
96
111
  err_console.print(f"[bold red]Error:[/bold red] {e}")
@@ -146,8 +161,21 @@ def _save_data_to_file(data: ContributionData, file_path: str) -> None:
146
161
  raise CLIError(f"Failed to save file '{file_path}': {e}")
147
162
 
148
163
 
149
- def _generate_gif(data: ContributionData, file_path: str, strategy_name: str) -> None:
164
+ def _generate_gif(
165
+ data: ContributionData,
166
+ file_path: str,
167
+ strategy_name: str,
168
+ fps: int,
169
+ watermark: bool,
170
+ maxFrame: int | None
171
+ ) -> None:
150
172
  """Generate animated GIF visualization."""
173
+ # GIF format limitation: delays below 20ms (>50 FPS) are clamped by most browsers
174
+ if fps > 50:
175
+ console.print(
176
+ f"[yellow]Warning:[/yellow] FPS > 50 may not display correctly in browsers "
177
+ f"(GIF delay will be {1000 // fps}ms, but browsers clamp delays < 20ms to ~100ms)"
178
+ )
151
179
  console.print("\n[bold blue]Generating GIF animation...[/bold blue]")
152
180
 
153
181
  if strategy_name == "column":
@@ -163,8 +191,12 @@ def _generate_gif(data: ContributionData, file_path: str, strategy_name: str) ->
163
191
 
164
192
  # Create animator and generate GIF
165
193
  try:
166
- animator = Animator(data, strategy)
167
- animator.generate_gif(file_path)
194
+ animator = Animator(data, strategy, fps=fps, watermark=watermark)
195
+ buffer = animator.generate_gif(maxFrame=maxFrame)
196
+ console.print("[bold blue]Saving GIF animation...[/bold blue]")
197
+ with open(file_path, "wb") as f:
198
+ f.write(buffer.getvalue())
199
+
168
200
  console.print(f"[green]✓[/green] GIF saved to {file_path}")
169
201
  except Exception as e:
170
202
  raise CLIError(f"Failed to generate GIF: {e}")
@@ -0,0 +1,30 @@
1
+ """Global constants for the application."""
2
+
3
+ # Animation settings
4
+ DEFAULT_FPS = 40 # Default frames per second for animation
5
+
6
+ # GitHub contribution graph dimensions
7
+ NUM_WEEKS = 52 # Number of weeks in contribution graph
8
+ NUM_DAYS = 7 # Number of days in a week (Sun-Sat)
9
+ SHIP_POSITION_Y = NUM_DAYS + 3 # Ship is positioned just below the grid
10
+
11
+ # Speeds in cells per second (frame-rate independent)
12
+ SHIP_SPEED = 12.5 # Cells per second the ship moves
13
+ BULLET_SPEED = 7.5 # Cells per second the bullet moves
14
+ BULLET_TRAILING_LENGTH = 3 # Number of trailing segments for bullets
15
+ BULLET_TRAIL_SPACING = 0.15 # Spacing between trail segments in cells
16
+
17
+ # Durations in seconds (frame-rate independent)
18
+ SHIP_SHOOT_COOLDOWN = 0.2 # Seconds between ship shots
19
+
20
+ # Explosion settings
21
+ EXPLOSION_PARTICLE_COUNT_LARGE = 8 # Number of particles in a large explosion
22
+ EXPLOSION_PARTICLE_COUNT_SMALL = 4 # Number of particles in a small explosion
23
+ EXPLOSION_MAX_RADIUS_LARGE = 20 # Max radius for large explosions
24
+ EXPLOSION_MAX_RADIUS_SMALL = 10 # Max radius for small explosions
25
+ EXPLOSION_DURATION_LARGE = 0.4 # Seconds for large explosion animation
26
+ EXPLOSION_DURATION_SMALL = 0.12 # Seconds for small explosion animation
27
+
28
+ # Starfield settings (speeds in cells per second)
29
+ STAR_SPEED_MIN = 1.0 # Minimum star speed (dimmer/farther stars)
30
+ STAR_SPEED_MAX = 2.5 # Maximum star speed (brighter/closer stars)
@@ -1,11 +1,10 @@
1
1
  """Animator for generating GIF animations from game strategies."""
2
2
 
3
+ from io import BytesIO
3
4
  from typing import Iterator
4
5
 
5
6
  from PIL import Image
6
7
 
7
-
8
- from ..constants import FRAME_DURATION_MS
9
8
  from ..github_client import ContributionData
10
9
  from .game_state import GameState
11
10
  from .renderer import Renderer
@@ -20,7 +19,8 @@ class Animator:
20
19
  self,
21
20
  contribution_data: ContributionData,
22
21
  strategy: BaseStrategy,
23
- frame_duration: int = FRAME_DURATION_MS,
22
+ fps: int,
23
+ watermark: bool = False,
24
24
  ):
25
25
  """
26
26
  Initialize animator.
@@ -28,13 +28,19 @@ class Animator:
28
28
  Args:
29
29
  contribution_data: The GitHub contribution data
30
30
  strategy: The strategy to use for clearing enemies
31
- frame_duration: Duration of each frame in milliseconds
31
+ fps: Frames per second for the animation
32
+ watermark: Whether to add watermark to the GIF
32
33
  """
33
34
  self.contribution_data = contribution_data
34
35
  self.strategy = strategy
35
- self.frame_duration = frame_duration
36
-
37
- def generate_gif(self, output_path: str) -> None:
36
+ self.fps = fps
37
+ self.watermark = watermark
38
+ self.frame_duration = 1000 // fps
39
+ # Delta time in seconds per frame
40
+ # Used to scale all speeds (cells/second) to per-frame movement
41
+ self.delta_time = 1.0 / fps
42
+
43
+ def generate_gif(self, maxFrame: int | None) -> BytesIO:
38
44
  """
39
45
  Generate animated GIF and save to file.
40
46
 
@@ -43,20 +49,27 @@ class Animator:
43
49
  """
44
50
  # Initialize game state
45
51
  game_state = GameState(self.contribution_data)
46
- renderer = Renderer(game_state, RenderContext.darkmode())
52
+ renderer = Renderer(game_state, RenderContext.darkmode(), watermark=self.watermark)
47
53
 
48
- frames = self._generate_frames(game_state, renderer)
54
+ frames: list[Image.Image] = []
55
+ for frame in self._generate_frames(game_state, renderer):
56
+ frames.append(frame)
57
+ if maxFrame is not None and len(frames) >= maxFrame:
58
+ break
49
59
 
50
- # Save as GIF
60
+ gif_buffer = BytesIO()
51
61
  if frames:
52
- next(frames).save(
53
- output_path,
62
+ frames[0].save(
63
+ gif_buffer,
64
+ format="gif",
54
65
  save_all=True,
55
- append_images=list(frames),
66
+ append_images=frames[1:],
56
67
  duration=self.frame_duration,
57
68
  loop=0, # Loop forever
58
69
  optimize=False,
59
70
  )
71
+
72
+ return gif_buffer
60
73
 
61
74
  def _generate_frames(
62
75
  self, game_state: GameState, renderer: Renderer
@@ -79,18 +92,18 @@ class Animator:
79
92
  for action in self.strategy.generate_actions(game_state):
80
93
  game_state.ship.move_to(action.x)
81
94
  while game_state.can_take_action() is False:
82
- game_state.animate()
95
+ game_state.animate(self.delta_time)
83
96
  yield renderer.render_frame()
84
97
 
85
98
  if action.shoot:
86
99
  game_state.shoot()
87
- game_state.animate()
100
+ game_state.animate(self.delta_time)
88
101
  yield renderer.render_frame()
89
102
 
90
103
  force_kill_countdown = 100
91
104
  # Add final frames showing completion
92
105
  while not game_state.is_complete():
93
- game_state.animate()
106
+ game_state.animate(self.delta_time)
94
107
  yield renderer.render_frame()
95
108
 
96
109
  force_kill_countdown -= 1
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
4
4
 
5
5
  from PIL import ImageDraw
6
6
 
7
- from ...constants import BULLET_SPEED, BULLET_TRAILING_LENGTH, SHIP_POSITION_Y
7
+ from ...constants import BULLET_SPEED, BULLET_TRAILING_LENGTH, BULLET_TRAIL_SPACING, SHIP_POSITION_Y
8
8
  from .drawable import Drawable
9
9
  from .explosion import Explosion
10
10
 
@@ -37,9 +37,13 @@ class Bullet(Drawable):
37
37
  return enemy
38
38
  return None
39
39
 
40
- def animate(self) -> None:
41
- """Update bullet position, check for collisions, and remove on hit."""
42
- self.y -= BULLET_SPEED
40
+ def animate(self, delta_time: float) -> None:
41
+ """Update bullet position, check for collisions, and remove on hit.
42
+
43
+ Args:
44
+ delta_time: Time elapsed since last frame in seconds.
45
+ """
46
+ self.y -= BULLET_SPEED * delta_time
43
47
  hit_enemy = self._check_collision()
44
48
  if hit_enemy:
45
49
  explosion = Explosion(self.x, self.y, "small", self.game_state)
@@ -53,7 +57,7 @@ class Bullet(Drawable):
53
57
  """Draw the bullet with trailing tail effect."""
54
58
 
55
59
  for i in range(BULLET_TRAILING_LENGTH):
56
- trail_y = self.y + (i + 1) * BULLET_SPEED
60
+ trail_y = self.y + (i + 1) * BULLET_TRAIL_SPACING
57
61
  fade_factor = (i + 1) / BULLET_TRAILING_LENGTH / 2
58
62
  self._draw_bullet(draw, context, (self.x, trail_y), fade_factor=fade_factor)
59
63
 
@@ -13,8 +13,12 @@ class Drawable(ABC):
13
13
  """Interface for objects that can be animated and drawn."""
14
14
 
15
15
  @abstractmethod
16
- def animate(self) -> None:
17
- """Update the object's state for the next animation frame."""
16
+ def animate(self, delta_time: float) -> None:
17
+ """Update the object's state for the next animation frame.
18
+
19
+ Args:
20
+ delta_time: Time elapsed since last frame in seconds.
21
+ """
18
22
  pass
19
23
 
20
24
  @abstractmethod
@@ -42,7 +42,7 @@ class Enemy(Drawable):
42
42
  self.game_state.explosions.append(explosion)
43
43
  self.game_state.enemies.remove(self)
44
44
 
45
- def animate(self) -> None:
45
+ def animate(self, delta_time: float) -> None:
46
46
  """Update enemy state for next frame (enemies don't animate currently)."""
47
47
  pass
48
48
 
@@ -7,8 +7,8 @@ from typing import TYPE_CHECKING, Literal
7
7
  from PIL import ImageDraw
8
8
 
9
9
  from ...constants import (
10
- EXPLOSION_MAX_FRAMES_LARGE,
11
- EXPLOSION_MAX_FRAMES_SMALL,
10
+ EXPLOSION_DURATION_LARGE,
11
+ EXPLOSION_DURATION_SMALL,
12
12
  EXPLOSION_MAX_RADIUS_LARGE,
13
13
  EXPLOSION_MAX_RADIUS_SMALL,
14
14
  EXPLOSION_PARTICLE_COUNT_LARGE,
@@ -37,22 +37,26 @@ class Explosion(Drawable):
37
37
  self.x = x
38
38
  self.y = y
39
39
  self.game_state = game_state
40
- self.frame = 0
41
- self.max_frames = EXPLOSION_MAX_FRAMES_SMALL if size == "small" else EXPLOSION_MAX_FRAMES_LARGE
40
+ self.elapsed_time = 0.0 # Seconds elapsed since explosion started
41
+ self.duration = EXPLOSION_DURATION_SMALL if size == "small" else EXPLOSION_DURATION_LARGE
42
42
  self.max_radius = EXPLOSION_MAX_RADIUS_SMALL if size == "small" else EXPLOSION_MAX_RADIUS_LARGE
43
43
  self.particle_count = EXPLOSION_PARTICLE_COUNT_SMALL if size == "small" else EXPLOSION_PARTICLE_COUNT_LARGE
44
44
  self.particle_angles = [random.uniform(0, 2 * math.pi) for _ in range(self.particle_count)]
45
45
 
46
- def animate(self) -> None:
47
- """Progress the explosion animation and remove when complete."""
48
- self.frame += 1
49
- if self.frame >= self.max_frames:
46
+ def animate(self, delta_time: float) -> None:
47
+ """Progress the explosion animation and remove when complete.
48
+
49
+ Args:
50
+ delta_time: Time elapsed since last frame in seconds.
51
+ """
52
+ self.elapsed_time += delta_time
53
+ if self.elapsed_time >= self.duration:
50
54
  self.game_state.explosions.remove(self)
51
55
 
52
56
  def draw(self, draw: ImageDraw.ImageDraw, context: "RenderContext") -> None:
53
57
  """Draw expanding particle explosion with fade effect."""
54
58
 
55
- progress = self.frame / self.max_frames
59
+ progress = self.elapsed_time / self.duration
56
60
  fade = 1 - progress # Fade out as animation progresses
57
61
 
58
62
  center_x, center_y = context.get_cell_position(self.x, self.y)
@@ -19,7 +19,7 @@ class Ship(Drawable):
19
19
  """Initialize the ship at starting position."""
20
20
  self.x: float = 25 # Start middle of screen
21
21
  self.target_x = self.x
22
- self.shoot_cooldown = 0 # Frames until ship can shoot again
22
+ self.shoot_cooldown = 0.0 # Seconds until ship can shoot again
23
23
  self.game_state = game_state
24
24
 
25
25
  def move_to(self, x: int):
@@ -37,18 +37,23 @@ class Ship(Drawable):
37
37
 
38
38
  def can_shoot(self) -> bool:
39
39
  """Check if ship can shoot (cooldown has finished)."""
40
- return self.shoot_cooldown == 0
40
+ return self.shoot_cooldown <= 0
41
41
 
42
- def animate(self) -> None:
43
- """Update ship position, moving toward target at constant speed."""
42
+ def animate(self, delta_time: float) -> None:
43
+ """Update ship position, moving toward target at constant speed.
44
+
45
+ Args:
46
+ delta_time: Time elapsed since last frame in seconds.
47
+ """
48
+ delta_x = SHIP_SPEED * delta_time
44
49
  if self.x < self.target_x:
45
- self.x = min(self.x + SHIP_SPEED, self.target_x)
50
+ self.x = min(self.x + delta_x, self.target_x)
46
51
  elif self.x > self.target_x:
47
- self.x = max(self.x - SHIP_SPEED, self.target_x)
52
+ self.x = max(self.x - delta_x, self.target_x)
48
53
 
49
- # Decrement shoot cooldown
54
+ # Decrement shoot cooldown (scaled by delta_time)
50
55
  if self.shoot_cooldown > 0:
51
- self.shoot_cooldown -= 1
56
+ self.shoot_cooldown -= delta_time
52
57
 
53
58
  def draw(self, draw: ImageDraw.ImageDraw, context: "RenderContext") -> None:
54
59
  """Draw a simple Galaga-style ship."""
@@ -1,23 +1,30 @@
1
1
  """Animated starfield background."""
2
2
 
3
3
  import random
4
- from typing import TYPE_CHECKING
4
+ from typing import TYPE_CHECKING, TypedDict
5
5
 
6
6
  from PIL import ImageDraw
7
7
 
8
- from ...constants import NUM_WEEKS, SHIP_POSITION_Y
8
+ from ...constants import NUM_WEEKS, SHIP_POSITION_Y, STAR_SPEED_MIN, STAR_SPEED_MAX
9
9
  from .drawable import Drawable
10
10
 
11
11
  if TYPE_CHECKING:
12
12
  from ..render_context import RenderContext
13
13
 
14
+ class Star(TypedDict):
15
+ x: float
16
+ y: float
17
+ brightness: float
18
+ size: int
19
+ speed: float
20
+
14
21
 
15
22
  class Starfield(Drawable):
16
23
  """Animated starfield background with slowly moving stars."""
17
24
 
18
- def __init__(self):
25
+ def __init__(self) -> None:
19
26
  """Initialize the starfield with random stars."""
20
- self.stars = []
27
+ self.stars: list[Star] = []
21
28
  # Generate about 100 stars across the play area
22
29
  for _ in range(100):
23
30
  # Random position across the entire grid area
@@ -27,25 +34,34 @@ class Starfield(Drawable):
27
34
  brightness = random.uniform(0.2, 1.0)
28
35
  # Size: 1-2 pixels
29
36
  size = random.choice([1, 1, 1, 2]) # More 1-pixel stars
30
- # Speed: slower for dimmer (farther) stars
31
- speed = 0.02 + (brightness * 0.03) # 0.02-0.05 cells per frame
32
- self.stars.append([x, y, brightness, size, speed])
37
+ # Speed: slower for dimmer (farther) stars (in cells per second)
38
+ speed = STAR_SPEED_MIN + (brightness * (STAR_SPEED_MAX - STAR_SPEED_MIN))
39
+ self.stars.append(
40
+ {"x": x, "y": y, "brightness": brightness, "size": size, "speed": speed}
41
+ )
42
+
43
+ def animate(self, delta_time: float) -> None:
44
+ """Move stars downward, wrapping around when they go off screen.
33
45
 
34
- def animate(self) -> None:
35
- """Move stars downward, wrapping around when they go off screen."""
46
+ Args:
47
+ delta_time: Time elapsed since last frame in seconds.
48
+ """
36
49
  for star in self.stars:
37
- # star[1] is the y position, star[4] is the speed
38
- star[1] += star[4]
50
+ star["y"] += star["speed"] * delta_time
39
51
 
40
52
  # Wrap around: if star goes below the screen, move it back to the top
41
- if star[1] > SHIP_POSITION_Y + 4:
42
- star[1] = -2
53
+ if star["y"] > SHIP_POSITION_Y + 4:
54
+ star["y"] = -2
43
55
  # Randomize x position when wrapping for variety
44
- star[0] = random.uniform(-2, NUM_WEEKS + 2)
56
+ star["x"] = random.uniform(-2, NUM_WEEKS + 2)
45
57
 
46
58
  def draw(self, draw: ImageDraw.ImageDraw, context: "RenderContext") -> None:
47
59
  """Draw all stars at their current positions."""
48
- for star_x, star_y, brightness, size, _ in self.stars:
60
+ for star in self.stars:
61
+ star_x = star["x"]
62
+ star_y = star["y"]
63
+ brightness = star["brightness"]
64
+ size = star["size"]
49
65
  # Convert grid position to pixel position
50
66
  x, y = context.get_cell_position(star_x, star_y)
51
67