diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5eb8630 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.11", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: pip install -e .[dev] + - name: Run tests (headless) + run: python -m pytest -q + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install ruff + run: pip install ruff + - name: Lint + run: ruff check . diff --git a/README.md b/README.md index d921f5b..bd8bba4 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,189 @@ -# Pygame Examples +# PyGame Lunar Lander +[![Python](https://img.shields.io/badge/python-3.9%2B-blue)](https://www.python.org/) -I always learn better from examples -- a long tutorial explaining the -background doesn't do it for me. To that end, here's a collection of -[PyGame](http://pygame.org) example games I've developed in the -process of learning PyGame. +A small arcade-style lunar landing simulation written with [PyGame](https://www.pygame.org/). -Right now there's: +Pilot the lander down to a gentle touchdown on the grey platform. Keep the +craft upright, watch your fuel, and avoid the boulders — a hard touchdown +destroys the ship. - - `snake.py`: a simple ~100-line version of Snake. - - `lunarlander.py`: a slightly-more-complicated Lunar Lander game. +## Controls -Happy coding! +| Key | Action | +|-----------------|---------------------------------| +| `SPACE` / `UP` | Fire the engine (boost) | +| `LEFT` | Rotate counter-clockwise | +| `RIGHT` | Rotate clockwise | +| `R` | Restart the game | +| close window | Quit | + +## Installation + +Requires Python 3.9+ and PyGame 2.1+. + +```bash +pip install -e . # install the game and the `lunar-lander` command +pip install -e ".[dev]" # also install pytest and ruff for development +``` + +## Running the game + +```bash +python -m lunarlander # or just: lunar-lander +``` + +## Running the tests + +The test-suite runs headlessly (no window is opened; the SDL `dummy` video +and audio drivers are used automatically via `conftest.py`). + +```bash +python -m pytest +ruff check . # lint +``` + +## Project layout + +``` +. +├── lunarlander/ # the game package +│ ├── __init__.py # public API re-exports +│ ├── __main__.py # `python -m lunarlander` +│ ├── constants.py # physics/terrain parameters and polled keys +│ ├── vectors.py # the `V` 2D vector type +│ ├── assets.py # image loading +│ ├── terrain.py # procedural sky/moon/boulders, `Moon`, `Boulder` +│ ├── sprites.py # the `Lander` sprite +│ ├── game.py # `LunarLander` game object + entry point +│ ├── lander.jpg # image assets +│ └── lander_flame.jpg +├── tests/ +│ └── test_lunarlander.py # 51 unit + integration tests +├── conftest.py # pytest config; initialises PyGame headlessly +├── pyproject.toml # packaging, ruff and pytest configuration +└── README.md +``` + +## Design + +The code is split into a small set of classes, each with a single +responsibility: + +- **`V`** — a minimal 2D vector. Supports Cartesian and polar + initialisation, and clockwise-angle semantics where `0` = up, `90` = right. +- **`Lander`** — physics (gravity, thrust, rotation), fuel, landing + detection, and the cached animated engine flame. +- **`Moon`** — the flat, safe landing platform (`landing_ok = True`). +- **`Boulder`** — an unsafe obstacle (`landing_ok = False`); touching one + always destroys the craft. +- **`LunarLander`** — the top-level game object. Owns the screen, sprites + and the main loop. `step()` advances the simulation one frame and is kept + separate from `draw()`, so the whole game can be driven headlessly. + +## Physics + +Physics is modeled in real-world **SI units** and integrated with a +delta-time (`dt`) step, so the simulation behaves identically at any frame +rate: + +| Quantity | Value | Description | +|--------------------|--------------|------------------------------------------| +| `GRAVITY` | `1.62 m/s²` | the Moon's surface gravity | +| `DRY_MASS` | `1500 kg` | lander mass with an empty tank | +| `FUEL_MASS` | `450 kg` | full-tank propellant mass | +| `FUEL_BURN_RATE` | `4 kg/s` | propellant flow while thrusting | +| `EXHAUST_VELOCITY` | `2500 m/s` | effective exhaust velocity | +| `ENGINE_THRUST` | `10 kN` | = burn rate × exhaust velocity | +| `LANDING_VELOCITY_LIMIT` | `3 m/s` | max safe touchdown speed | +| `PIXELS_PER_METER` | `8` | world-to-screen scale | + +The rocket equation behaviour is real: + +``` +acceleration = (thrust − weight) / mass = (thrust − mass·g) / mass +``` + +As propellant burns, `mass` falls and thrust-to-weight ratio *rises* from +≈3.2 to ≈4.1 — the craft becomes progressively more responsive, exactly +like a real rocket. Coasting descends at the Moon's gravity. Position and +speed are stored in float so sub-pixel motion is never lost. + +Two extra measures keep the simulation robust: + +- **Tunnelling prevention** — collision detection is *swept*: if the craft + crossed the surface's top edge during the previous frame it is snapped + back on top, so it can never fall through the floor between frames. +- **Delta-time clamping** — `dt` is clamped to `MAX_DT = 0.05 s` so a slow + frame (window drag, background process) cannot teleport the craft. + +## Performance + +A few choices keep the game fast at 80 FPS: + +- **Rotated-frame caching** — `pygame.transform.rotate` is expensive and is + only called when the orientation actually changes; every rotation is + cached by `(image, rounded angle)`. +- **Fonts are created once** — the previous version re-created a `Font` + object every single frame. +- **No redundant redraws** — the old code called `display.flip()` *and* + `display.update()` every frame; only `flip()` is needed. +- **No event flooding** — `key.set_repeat(1, 1)` (which queued hundreds of + KEYDOWN events per second) has been removed; input is polled once per + frame with `get_pressed()`. +- **In-place vector math** — `V.__iadd__` avoids allocating a new vector on + every physics update. + +## Changes from the original + +The original `pygame-lunarlander` was Python 2 code that no longer ran under +modern PyGame. Besides the port to Python 3, this fork fixes: + +1. **`V.rotate` corrupting vectors** — `self.y` was computed with the + already-updated `self.x`. +2. **`V.angle` returning wrong quadrants** — replaced a broken + `atan(x/y)` implementation with `atan2` and normalised to `[0, 360)`. +3. **Negative orientations counted as "upright"** — `-20°` passed the old + `orientation < 10` check. Orientation is now kept in `[0, 360)`. +4. **Sprites never initialised properly** — `DirtySprite.__init__` was never + called via a broken `super()` dance; sprites now call `super().__init__()`. +5. **Boulders rendered as black squares** — the rock surface now uses + per-pixel alpha (`SRCALPHA`). +6. **Moon drawn over the landed ship** — draw order was inverted; the moon + now renders behind the lander. +7. **`time.sleep(1)` freezing the game** — the post-landing delay is now + frame-based, so the window still responds. +8. **Moon tunnelling** — see *Tunnelling prevention* above. +9. **`except pygame.error, message:` / `print`** — Python 2 syntax, converted + to Python 3. +10. **Unrealistic, uncontrollable physics** — the original gravity (0.5 + px/frame²) and impulse-style engine made the craft either plummet in + under a second or rocket off-screen. Physics is now real SI rocket + mechanics (see [Physics](#physics)): Moon gravity, mass loss, rising + thrust-to-weight ratio, and a gentle `3 m/s` touchdown limit. +11. **Sub-pixel movement was lost** — `Rect.move()` truncates float offsets + to integers, so tiny per-frame motion was discarded. Position is now + accumulated in float and only rounded for rendering. +12. **Flat-circle terrain** — the surface is now a procedurally drawn lunar + landscape: starfield, cratered ground, a marked landing pad and + irregular, shaded boulders. +13. **No time to prepare** — the game now has a *ready* screen; press SPACE + to launch instead of starting mid-fall. +14. **Dead keyboard input** — `pygame.key.get_pressed()` is *scancode*-indexed, + so keys built from `enumerate(...)` never matched the `K_*` constants. + Input is now polled via the `K_*` constants in `pressed_keys`. +15. **Thrust discarded when fuel ran out mid-frame** — the last partial burn + now delivers its impulse (`thrust = burned/dt × exhaust velocity`) instead + of being dropped. Also: `V` equality uses `math.isclose`, and `load_image` + converts per-pixel-alpha images with `convert_alpha()`. +16. **Packaged as a proper module** — the single-file `lunarlander.py` was + split into a package (`constants`, `vectors`, `assets`, `terrain`, + `sprites`, `game`), with `pyproject.toml` for packaging/linting, `ruff` + linting and a GitHub Actions CI workflow (badges above). + +## Contributing + +- Run `python -m pytest` and `ruff check .` before pushing — all tests must + pass and lint must be clean (CI enforces both). +- Keep physics in SI units and `dt`-based so behaviour is frame-rate + independent. +- Add a test for any new behaviour in `tests/test_lunarlander.py`. diff --git a/brickout-game/README.md b/brickout-game/README.md deleted file mode 100644 index bf89271..0000000 --- a/brickout-game/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Brickout-Game in pygame - -This is a simple brickout-game. It's includes ball, paddle, brick wall -and additional collision detection between this objects. Furthermore logic for winning and losing and -scoring. The game is written in **Python 2**. - -You start the game with ```python game.py``` - -The game runs with 60 frames per second. - -I used a code sample for the basic structure of pygame from this site [http://programarcadegames.com/](http://programarcadegames.com/) -The site contains a comprehensive introduction in Python and Pygame. In addition many many examples. - diff --git a/brickout-game/classDiagram.png b/brickout-game/classDiagram.png deleted file mode 100644 index 3cea76b..0000000 Binary files a/brickout-game/classDiagram.png and /dev/null differ diff --git a/brickout-game/game.py b/brickout-game/game.py deleted file mode 100644 index e54c94a..0000000 --- a/brickout-game/game.py +++ /dev/null @@ -1,342 +0,0 @@ -""" - Pygame base template for opening a window - - Sample Python/Pygame Programs - Simpson College Computer Science - http://programarcadegames.com/ - http://simpson.edu/computer-science/ - - Explanation video: http://youtu.be/vRB_983kUMc - -------------------------------------------------- - -Author for the Brickout game is Christian Bender -That includes the classes Ball, Paddle, Brick, and BrickWall. - -""" - -import pygame - - -# Define some colors -BLACK = (0, 0, 0) -WHITE = (255, 255, 255) -GREEN = (0, 255, 0) -RED = (255, 0, 0) - -pygame.init() - -# Set the width and height of the screen [width, height] -size = (700, 500) -screen = pygame.display.set_mode(size) - -""" - This is a simple Ball class for respresenting a ball - in the game. -""" -class Ball(object): - def __init__ (self, screen, radius,x,y): - self.__screen = screen - self._radius = radius - self._xLoc = x - self._yLoc = y - self.__xVel = 5 - self.__yVel = -3 - w, h = pygame.display.get_surface().get_size() - self.__width = w - self.__height = h - def draw(self): - """ - draws the ball onto screen. - """ - pygame.draw.circle(screen,(255, 0, 0) , (self._xLoc,self._yLoc), self._radius) - def update(self, paddle, brickwall): - """ - moves the ball at the screen. - contains some collision detection. - """ - self._xLoc += self.__xVel - self._yLoc += self.__yVel - if self._xLoc == self._radius: - self.__xVel *= -1 - elif self._xLoc >= self.__width - self._radius: - self.__xVel *= -1 - if self._yLoc == self._radius: - self.__yVel *= -1 - elif self._yLoc >= self.__height - self._radius: - return True - - # for bouncing off the bricks. - if brickwall.collide(self): - self.__yVel *= -1 - - # collision deection between ball and paddle - paddleX = paddle._xLoc - paddleY = paddle._yLoc - paddleW = paddle._width - paddleH = paddle._height - ballX = self._xLoc - ballY = self._yLoc - - if ((ballX + self._radius) >= paddleX and ballX <= (paddleX + paddleW)) \ - and ((ballY + self._radius) >= paddleY and ballY <= (paddleY + paddleH)): - self.__yVel *= -1 - - return False - - -""" - Simple class for representing a paddle -""" -class Paddle (object): - def __init__ (self, screen, width, height,x,y): - self.__screen = screen - self._width = width - self._height = height - self._xLoc = x - self._yLoc = y - w, h = pygame.display.get_surface().get_size() - self.__W = w - self.__H = h - def draw(self): - """ - draws the paddle onto screen. - """ - pygame.draw.rect(screen, (0,0,0), (self._xLoc,self._yLoc,self._width,self._height),0) - def update(self): - """ - moves the paddle at the screen via mouse - """ - x,y = pygame.mouse.get_pos() - if x >= 0 and x <= (self.__W - self._width): - self._xLoc = x - -""" - This class represents a simple Brick class. - For representing bricks onto screen. -""" -class Brick (pygame.sprite.Sprite): - def __init__(self, screen, width, height, x,y): - self.__screen = screen - self._width = width - self._height = height - self._xLoc = x - self._yLoc = y - w, h = pygame.display.get_surface().get_size() - self.__W = w - self.__H = h - self.__isInGroup = False - def draw(self): - """ - draws the brick onto screen. - color: rgb(56, 177, 237) - """ - pygame.draw.rect(screen, (56, 177, 237), (self._xLoc,self._yLoc,self._width,self._height),0) - def add (self, group): - """ - adds this brick to a given group. - """ - group.add(self) - self.__isInGroup = True - def remove(self, group): - """ - removes this brick from the given group. - """ - group.remove(self) - self.__isInGroup = False - def alive(self): - """ - returns true when this brick is belong to the brick wall. - otherwise false - """ - return self.__isInGroup - - def collide(self, ball): - """ - collision deection between ball and this brick - """ - brickX = self._xLoc - brickY = self._yLoc - brickW = self._width - brickH = self._height - ballX = ball._xLoc - ballY = ball._yLoc - radius = ball._radius - - if ((ballX + radius) >= brickX and ballX <= (brickX + brickW)) \ - and ((ballY + radius) >= brickY and ballY <= (brickY + brickH)): - return True - - return False - - -""" - This is a simple class for representing a - brick wall. -""" -class BrickWall (pygame.sprite.Group): - def __init__ (self,screen, x, y, width, height): - self.__screen = screen - self._x = x - self._y = y - self._width = width - self._height = height - self._bricks = [] - - X = x - Y = y - for i in range(3): - for j in range(4): - self._bricks.append(Brick(screen,width,height,X,Y)) - X += width + (width/ 7.0) - Y += height + (height / 7.0) - X = x - - def add(self,brick): - """ - adds a brick to this BrickWall (group) - """ - self._bricks.append(brick) - def remove(self,brick): - """ - removes a brick from this BrickWall (group) - """ - self._bricks.remove(brick) - def draw(self): - """ - draws all bricks onto screen. - """ - for brick in self._bricks: - if brick != None: - brick.draw() - def update(self, ball): - """ - checks collision between ball and bricks. - """ - for i in range(len(self._bricks)): - if ((self._bricks[i] != None) and self._bricks[i].collide(ball)): - self._bricks[i] = None - - # removes the None-elements from the brick list. - for brick in self._bricks: - if brick == None: - self._bricks.remove(brick) - def hasWin(self): - """ - Has player win the game? - """ - return len(self._bricks) == 0 - def collide (self, ball): - """ - check collisions between the ball and - any of the bricks. - """ - for brick in self._bricks: - if brick.collide(ball): - return True - return False - -# The game objects ball, paddle and brick wall -ball = Ball(screen,25,350,250) -paddle = Paddle(screen,100,20,250,450) -brickWall = BrickWall(screen,25,25,150,50) - -isGameOver = False # determines whether game is lose -gameStatus = True # game is still running - -score = 0 # score for the game. - -pygame.display.set_caption("Brickout-game") - -# Loop until the user clicks the close button. -done = False - -# Used to manage how fast the screen updates -clock = pygame.time.Clock() - -# for displaying text in the game -pygame.font.init() # you have to call this at the start, - # if you want to use this module. - -# message for game over -mgGameOver = pygame.font.SysFont('Comic Sans MS', 60) - -# message for winning the game. -mgWin = pygame.font.SysFont('Comic Sans MS', 60) - -# message for score -mgScore = pygame.font.SysFont('Comic Sans MS', 60) - -textsurfaceGameOver = mgGameOver.render('Game Over!', False, (0, 0, 0)) -textsurfaceWin = mgWin.render("You win!",False,(0,0,0)) -textsurfaceScore = mgScore.render("score: "+str(score),False,(0,0,0)) - - -# -------- Main Program Loop ----------- -while not done: - # --- Main event loop - for event in pygame.event.get(): - if event.type == pygame.QUIT: - done = True - - # --- Game logic should go here - - # --- Screen-clearing code goes here - - # Here, we clear the screen to white. Don't put other drawing commands - # above this, or they will be erased with this command. - - # If you want a background image, replace this clear with blit'ing the - # background image. - screen.fill(WHITE) - - # --- Drawing code should go here - - """ - Because I use OOP in the game logic and the drawing code, - are both in the same section. - """ - if gameStatus: - - # first draws ball for appropriate displaying the score. - brickWall.draw() - - # for counting and displaying the score - if brickWall.collide(ball): - score += 10 - textsurfaceScore = mgScore.render("score: "+str(score),False,(0,0,0)) - screen.blit(textsurfaceScore,(300,0)) - - # after scoring. because hit bricks are removed in the update-method - brickWall.update(ball) - - paddle.draw() - paddle.update() - - if ball.update(paddle, brickWall): - isGameOver = True - gameStatus = False - - if brickWall.hasWin(): - gameStatus = False - - ball.draw() - - else: # game isn't running. - if isGameOver: # player lose - screen.blit(textsurfaceGameOver,(0,0)) - textsurfaceScore = mgScore.render("score: "+str(score),False,(0,0,0)) - screen.blit(textsurfaceScore,(300,0)) - elif brickWall.hasWin(): # player win - screen.blit(textsurfaceWin,(0,0)) - textsurfaceScore = mgScore.render("score: "+str(score),False,(0,0,0)) - screen.blit(textsurfaceScore,(300,0)) - - # --- Go ahead and update the screen with what we've drawn. - pygame.display.flip() - - # --- Limit to 60 frames per second - clock.tick(60) - -# Close the window and quit. -pygame.quit() \ No newline at end of file diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..cb10c3f --- /dev/null +++ b/conftest.py @@ -0,0 +1,23 @@ +"""Pytest configuration. + +Runs PyGame in headless "dummy" mode so the test-suite can execute without +opening a window or using an audio device. The environment variables are +set before ``pygame`` is imported. +""" + +import os + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") +os.environ.setdefault("SDL_AUDIODRIVER", "dummy") + +import pygame +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def pygame_runtime(): + """Initialise PyGame once per test session and tear it down at the end.""" + pygame.init() + pygame.display.set_mode((800, 600)) + yield + pygame.quit() diff --git a/lunarlander.py b/lunarlander.py index 85d20f7..74c13c4 100644 --- a/lunarlander.py +++ b/lunarlander.py @@ -1,280 +1,725 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 +"""PyGame Lunar Lander. + +A small arcade-style lunar landing simulation written with PyGame. All +terrain (starfield, cratered moon surface, landing pad, boulders) is +drawn procedurally, so the game has no external asset dependencies beyond +the bundled lander sprites. + +Controls +-------- +* ``SPACE`` / ``UP`` -- thrust (also launches the craft from the ready screen) +* ``LEFT`` / ``RIGHT`` -- rotate +* ``R`` -- restart + +Objective +--------- +Pilot the lander down to a gentle touchdown on the lunar surface. A +touchdown is successful when the craft is upright and slow enough +(velocity below ``LANDING_VELOCITY_LIMIT``). Touching down in any other +way, or hitting one of the boulders on the surface, destroys the craft. +Landing inside the marked pad is a *perfect* landing. + +Physics +------- +Real-world physics in SI units, integrated with a delta-time step so the +simulation behaves identically at any frame rate: + +* gravity is the Moon's surface gravity (``1.62 m/s^2``), +* the lander has a dry mass plus a fuel mass that burns at a fixed rate, +* the engine produces a constant thrust in newtons along the craft's + facing direction; acceleration is ``(thrust - weight) / mass`` and so + grows as fuel is consumed (real rocket behaviour), +* positions are converted from metres to pixels with ``PIXELS_PER_METER``. + +The game rules live in classes that can be unit-tested without a display +window (see ``tests/``); only ``main()`` needs a real window. +""" -import pygame -import sys -import time +import math import os import random -import math -from pygame.locals import RLEACCEL, QUIT, K_r, K_SPACE, K_UP, K_LEFT, K_RIGHT -#from pygame.locals import * +import pygame +from pygame.locals import ( + K_LEFT, + K_RIGHT, + K_SPACE, + K_UP, + K_r, + QUIT, + RLEACCEL, +) + +# Keys whose state is polled each frame; indices into the sequence returned by +# pygame.key.get_pressed(). +POLLED_KEYS = (K_r, K_SPACE, K_UP, K_LEFT, K_RIGHT) + + +def pressed_keys(pressed): + """Map the sequence from ``pygame.key.get_pressed()`` to pressed key + constants. + + ``get_pressed()`` returns a scancode-indexed sequence, so its raw indices + cannot be compared with the ``K_*`` constants; the wrapper's ``__getitem__`` + performs that translation for us. + """ + return [key for key in POLLED_KEYS if pressed[key]] + + +# --------------------------------------------------------------------------- +# Tunable game parameters +# --------------------------------------------------------------------------- FPS = 80 +SCREEN_WIDTH, SCREEN_HEIGHT = 800, 600 -pygame.init() +# Real-world physics, SI units. The Moon's surface gravity is used and the +# lander's mass falls as fuel is burnt, so thrust-to-weight ratio (and +# therefore acceleration) rises over time, exactly like a real rocket. +GRAVITY = 1.62 # Moon surface gravity, m/s^2 +PIXELS_PER_METER = 8.0 # world-to-screen scale +DRY_MASS = 1500.0 # kg, empty lander +FUEL_MASS = 450.0 # kg, full tank +FUEL_BURN_RATE = 4.0 # kg/s propellant flow rate +EXHAUST_VELOCITY = 2500.0 # m/s effective exhaust velocity +ENGINE_THRUST = FUEL_BURN_RATE * EXHAUST_VELOCITY # N (= 10 kN) +LANDING_VELOCITY_LIMIT = 3.0 # max touchdown speed, m/s -fpsClock=pygame.time.Clock() +ROTATION_SPEED = 40.0 # degrees/s while a rotation key is held +LANDING_ORIENTATION_TOLERANCE = 10 # max deviation from upright, degrees +RESET_DELAY = 2.0 # seconds the result screen is shown +MAX_DT = 0.05 # clamp for slow frames, seconds -SCREEN_WIDTH, SCREEN_HEIGHT = 800, 600 -ARENA_WIDTH, ARENA_HEIGHT = 10 * SCREEN_WIDTH, 10 * SCREEN_HEIGHT +# Default integration step used when no real clock is available (tests). +DEFAULT_DT = 1.0 / FPS + +# Terrain. +MOON_HEIGHT = 90 # visible height of the lunar surface strip +PAD_WIDTH = 150 # width of the landing pad +BOULDER_MIN, BOULDER_MAX = 22, 64 -screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), 0, 32) -surface = pygame.Surface(screen.get_size()) -surface = surface.convert() -surface.fill((255,255,255)) -clock = pygame.time.Clock() -pygame.key.set_repeat(1, 1) +# --------------------------------------------------------------------------- +# Asset loading +# --------------------------------------------------------------------------- + +def _asset_path(name): + """Return the absolute path of an image inside the bundled assets dir.""" + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "lunarlander", name) + def load_image(name, colorkey=None): - fullname = os.path.join('lunarlander', name) + """Load an image, optionally applying a transparent colorkey. + + Returns a ``(surface, rect)`` tuple. The image is converted to the + display format so blitting is fast. Raises ``FileNotFoundError`` if + the asset cannot be found. + """ + fullname = _asset_path(name) try: image = pygame.image.load(fullname) - except pygame.error, message: - print 'Cannot load image:', fullname - raise SystemExit(message) + except pygame.error as exc: + raise FileNotFoundError("Cannot load image: {}".format(fullname)) from exc image = image.convert() if colorkey is not None: - if colorkey is -1: - colorkey = image.get_at((0,0)) + if colorkey == -1: + colorkey = image.get_at((0, 0)) image.set_colorkey(colorkey, RLEACCEL) return image, image.get_rect() -class V(object): - """ - A simple class to keep track of vectors, including initializing - from Cartesian and polar forms. + +# --------------------------------------------------------------------------- +# 2D vector helper +# --------------------------------------------------------------------------- + +class V: + """A minimal 2D vector. + + Supports initialisation from Cartesian (``x``, ``y``) or polar + (``angle``, ``magnitude``) form. Angles are measured in degrees, + clockwise from "up", matching the screen-space movement rules of the + lander: angle 0 means straight up and 90 means to the right. """ - def __init__(self, x=0, y=0, angle=None, magnitude=None): - self.x = x - self.y = y - if (angle is not None and magnitude is not None): - self.x = magnitude * math.sin(math.radians(angle)) - self.y = magnitude * math.cos(math.radians(angle)) + __slots__ = ("x", "y") + + def __init__(self, x=0.0, y=0.0, angle=None, magnitude=None): + self.x = float(x) + self.y = float(y) + if angle is not None and magnitude is not None: + rad = math.radians(angle) + self.x = magnitude * math.sin(rad) + self.y = magnitude * math.cos(rad) @property def magnitude(self): - return math.sqrt(self.x ** 2 + self.y ** 2) - + """Length of the vector.""" + return math.hypot(self.x, self.y) + @property def angle(self): - if self.y == 0: - if self.x > 0: - return 90.0 - else: - return 270.0 - if math.floor(self.x) == 0: - if self.y < 0: - return 180.0 - return math.degrees(math.atan(self.x / float(self.y))) + """Direction in degrees clockwise from up, normalised to [0, 360).""" + return math.degrees(math.atan2(self.x, self.y)) % 360.0 def __add__(self, other): - return V(x=(self.x + other.x), y=(self.y + other.y)) + return V(self.x + other.x, self.y + other.y) + + def __iadd__(self, other): + self.x += other.x + self.y += other.y + return self def rotate(self, angle): - c = math.cos(math.radians(angle)) - s = math.sin(math.radians(angle)) - self.x = self.x * c - self.y * s - self.y = self.x * s + self.y * c + """Rotate the vector clockwise by ``angle`` degrees, in place.""" + rad = math.radians(angle) + c, s = math.cos(rad), math.sin(rad) + x, y = self.x, self.y + self.x = x * c - y * s + self.y = x * s + y * c + + def __eq__(self, other): + return self.x == other.x and self.y == other.y + + def __repr__(self): + return "V(x={:.2f}, y={:.2f})".format(self.x, self.y) + + +# --------------------------------------------------------------------------- +# Procedural terrain +# --------------------------------------------------------------------------- + +def make_sky(): + """Generate the static starfield + planet background once.""" + sky = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)) + rng = random.Random() + horizon = SCREEN_HEIGHT - MOON_HEIGHT + + top = (6, 8, 26) + bottom = (24, 30, 60) + for y in range(SCREEN_HEIGHT): + t = y / SCREEN_HEIGHT + color = tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)) + pygame.draw.line(sky, color, (0, y), (SCREEN_WIDTH, y)) + + # Soft glow above the horizon. + for y in range(max(0, horizon - 45), horizon): + t = (y - (horizon - 45)) / 45.0 + glow = int(36 + 44 * t) + pygame.draw.line(sky, (glow, glow, glow), (0, y), (SCREEN_WIDTH, y)) + + # Stars. + for _ in range(180): + x = rng.randrange(0, SCREEN_WIDTH) + y = rng.randrange(0, horizon) + if rng.random() < 0.7: + sky.set_at((x, y), rng.choice([(255, 255, 255), (200, 210, 255), (255, 230, 200)])) + else: + pygame.draw.circle(sky, rng.choice([(255, 255, 255), (180, 200, 255)]), (x, y), 1) + + # A distant planet, top-right. + planet = pygame.Surface((130, 130), pygame.SRCALPHA) + pygame.draw.circle(planet, (60, 110, 180), (65, 65), 62) + for px, py, pr in [(48, 48, 24), (80, 58, 20), (55, 84, 28)]: + pygame.draw.circle(planet, (80, 150, 205), (px, py), pr) + pygame.draw.circle(planet, (200, 230, 255), (40, 36), 58, 2) + sky.blit(planet, (SCREEN_WIDTH - 150, 20)) + return sky + + +def make_moon_surface(): + """Generate a cratered lunar surface strip with a landing pad.""" + w = SCREEN_WIDTH + 20 + h = MOON_HEIGHT + rng = random.Random() + moon = pygame.Surface((w, h)) + + # Vertical gradient: darker at the horizon, lighter towards the bottom. + top = (148, 148, 156) + bottom = (205, 205, 212) + for y in range(h): + t = y / h + color = tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)) + moon.fill(color, (0, y, w, 1)) + + # Speckle noise for texture. + for _ in range(1200): + x = rng.randrange(0, w) + y = rng.randrange(0, h) + d = rng.choice((110, 125, 140, 160, 185, 210)) + moon.set_at((x, y), (d, d, d)) + + # Craters: dark bowl, mid-grey floor and a lit rim on the sun side. + for _ in range(rng.randint(5, 9)): + cx = rng.randrange(25, w - 25) + cy = rng.randrange(16, h - 6) + r = rng.randrange(6, 20) + pygame.draw.circle(moon, (108, 108, 116), (cx, cy), r) + pygame.draw.circle(moon, (138, 138, 146), (cx, cy), r - 2) + pygame.draw.circle(moon, (228, 228, 235), (cx - 2, cy - 2), max(1, r - 4), 1) + + _draw_pad(moon, w, h) + return moon + + +def _draw_pad(moon, w, h): + """Paint a clearly marked landing pad on the surface strip.""" + pad_w = PAD_WIDTH + x0 = SCREEN_WIDTH // 2 - pad_w // 2 + pad = pygame.Surface((pad_w, h), pygame.SRCALPHA) + pad.fill((215, 210, 185, 130)) + + # Target concentric to the landing surface. + cx, cy = pad_w // 2, 16 + for radius in (6, 12, 18): + pygame.draw.circle(pad, (90, 85, 70, 200), (cx, cy), radius, 2) + pygame.draw.circle(pad, (90, 85, 70, 200), (cx, cy), 2) + + # Dashed edge markers down each side of the pad. + for yy in range(4, h, 8): + pygame.draw.line(pad, (120, 115, 95, 200), (2, yy), (6, yy)) + pygame.draw.line(pad, (120, 115, 95, 200), (pad_w - 6, yy), (pad_w - 2, yy)) + + moon.blit(pad, (x0, 0)) + + +def make_boulder_image(size, rng): + """Generate an irregular, shaded rocky boulder as an RGBA surface.""" + surf = pygame.Surface((size, size), pygame.SRCALPHA) + c = size // 2 + points = [] + for i in range(9): + a = 2 * math.pi * i / 9 + r = size * 0.5 * rng.uniform(0.70, 1.05) + points.append((c + r * math.cos(a), c + r * math.sin(a))) + + pygame.draw.polygon(surf, (140, 137, 132), points) + pygame.draw.polygon(surf, (96, 93, 88), points, 2) + highlight = [(x - 1, y - 1) for (x, y) in points] + pygame.draw.polygon(surf, (180, 177, 172), highlight, 1) + for _ in range(12): + x = c + rng.randrange(-size // 3, size // 3) + y = c + rng.randrange(-size // 3, size // 3) + surf.set_at((x, y), (95, 95, 95)) + return surf + + +def random_boulder_x(rng=random): + """Return a surface x-position outside the landing pad.""" + while True: + x = rng.randint(0, SCREEN_WIDTH) + if abs(x - SCREEN_WIDTH // 2) > PAD_WIDTH // 2 + 45: + return x - def __str__(self): - return "X: %.3d Y: %.3d Angle: %.3d degrees Magnitude: %.3d" % (self.x, self.y, self.angle, self.magnitude) + +# --------------------------------------------------------------------------- +# Game objects +# --------------------------------------------------------------------------- class Lander(pygame.sprite.DirtySprite): + """The player-controlled lunar lander. + + Handles its own physics (gravity, continuous thrust), rotation, fuel, + landing detection and the engine flame. Rotated frames are cached so + the expensive ``pygame.transform.rotate`` call only runs when the + orientation actually changes. """ - Our intrepid lunar lander! - """ - def __init__(self): - self.image, self.rect = load_image('lander.jpg', -1) - - self.original = self.image - self.original_flame, self.flame_rect = load_image('lander_flame.jpg', -1) - - self.mass = 10 - self.orientation = 0.0 # - self.rect.topleft = ((SCREEN_WIDTH / 2), 20) # The starting point. - self.engine_power = 2 # The power of the engine. - self.velocity = V(0.0,0.0) # Starting velocity. - self.landed = False # Have we landed yet? - self.intact = True # Is the ship still shipshape? - self.fuel = 100 # Units of fuel - self.boosting = 0 # Are we in "boost" mode? (show the flame graphic) - return super(pygame.sprite.DirtySprite, self).__init__() + + def __init__(self, image=None, flame_image=None): + super().__init__() + if image is None: + image, _ = load_image("lander.jpg", -1) + if flame_image is None: + flame_image, _ = load_image("lander_flame.jpg", -1) + + self.original = image + self.original_flame = flame_image + self.image = image + self.rect = image.get_rect(topleft=(SCREEN_WIDTH // 2, 20)) + + self.orientation = 0.0 + self.velocity = V() # metres/second + self.landed = False + self.intact = True + self.fuel = FUEL_MASS # kilograms of propellant + self.boosting = False + + # Position is tracked in float so sub-pixel motion accumulates; + # PyGame rects are integer-only and would swallow small steps. + self._px, self._py = float(self.rect.centerx), float(self.rect.centery) + self._prev_rect = self.rect.copy() + self._rotation_cache = {} + + # -- helpers ------------------------------------------------------------ + + def _sync_center(self): + """Re-read the float position from the (integer) rect after a direct move.""" + self._px, self._py = float(self.rect.centerx), float(self.rect.centery) + + def _rotated(self, img, angle): + """Return ``img`` rotated by ``angle`` degrees, caching the result.""" + key = (id(img), int(round(angle))) + rotated = self._rotation_cache.get(key) + if rotated is None: + rotated = pygame.transform.rotate(img, angle) + self._rotation_cache[key] = rotated + return rotated def update_image(self): - """ - Update our image based on orientation and engine state of the craft. - """ + """Refresh the sprite image from the current orientation/engine state.""" img = self.original_flame if self.boosting else self.original - center = self.rect.center - self.image = pygame.transform.rotate(img, -1 * self.orientation) - self.rect = self.image.get_rect(center=center) + rotated = self._rotated(img, -1 * self.orientation) + self.image = rotated + self.rect = rotated.get_rect(center=(round(self._px), round(self._py))) + + # -- player actions ----------------------------------------------------- + + def set_boosting(self, active): + """Turn the engine on/off; it only fires while fuel remains.""" + self.boosting = active and self.fuel > 0 def rotate(self, angle): - """ - Rotate the craft. - """ - self.orientation += angle + """Rotate the craft by ``angle`` degrees, keeping orientation in [0, 360).""" + self.orientation = (self.orientation + angle) % 360.0 - def boost(self): - """ - Provide a boost to our craft's velocity in whatever orientation we're currently facing. + @property + def mass(self): + """Current lander mass: dry mass plus remaining fuel, in kg.""" + return DRY_MASS + self.fuel + + def physics_update(self, dt): + """Integrate one physics step of ``dt`` seconds. + + While the engine is on it burns ``FUEL_BURN_RATE`` kg/s of fuel and + produces ``ENGINE_THRUST`` newtons along the craft's facing + direction. Acceleration is ``(thrust - weight) / mass``, so it + grows as the tank empties. Coasting falls at the Moon's gravity. """ - if not self.fuel: return - self.velocity += V(magnitude=self.engine_power, angle=self.orientation) - self.fuel -= 1 if self.landed: - self.landed = False - np = self.rect.move(0, -5) - self.rect = np - self.boosting = 3 - - def physics_update(self): - if not self.landed: - self.velocity += V(magnitude=.5, angle=180) + return + if self.boosting: + burn = min(FUEL_BURN_RATE * dt, self.fuel) + self.fuel -= burn + if self.fuel <= 0: + self.boosting = False + self.velocity += V(magnitude=GRAVITY * dt, angle=180) + return + accel = ENGINE_THRUST / self.mass - GRAVITY + self.velocity += V(magnitude=accel * dt, angle=self.orientation) + else: + self.velocity += V(magnitude=GRAVITY * dt, angle=180) def ok_to_land(self): - return (self.orientation < 10 or self.orientation > 350) and self.velocity.magnitude < 5 + """True when the craft is nearly upright and moving slowly enough.""" + orientation = self.orientation % 360.0 + upright = ( + orientation < LANDING_ORIENTATION_TOLERANCE + or orientation > 360.0 - LANDING_ORIENTATION_TOLERANCE + ) + return upright and self.velocity.magnitude < LANDING_VELOCITY_LIMIT def check_landed(self, surface): - if self.landed: return + """Resolve a touchdown against ``surface`` (a ``Moon`` or ``Boulder``). + + Prevents tunnelling: if the craft crossed the surface's top edge + since the previous frame, it is snapped back on top instead of + sinking through it. + """ + if self.landed: + return if hasattr(surface, "radius"): collision = pygame.sprite.collide_circle(self, surface) else: collision = pygame.sprite.collide_rect(self, surface) - if collision: - self.landed = True - if self.ok_to_land() and surface.landing_ok: - self.intact = True - else: - # Hard landing, kaboom! - self.intact = False - self.velocity = V(0.0,0.0) # In any case, we stop moving. - - def update(self): - self.physics_update() # Iterate physics - if self.boosting: - self.boosting -= 1 # Tick over engine time + if not collision: + return + if surface.landing_ok and self._prev_rect.bottom <= surface.rect.top <= self.rect.bottom: + self.rect.bottom = surface.rect.top + self.landed = True + self.intact = self.ok_to_land() and surface.landing_ok + self.velocity = V() + self._sync_center() + + # -- per-frame update --------------------------------------------------- + + def update(self, dt): + """Advance the craft by ``dt`` seconds: physics, animation, movement.""" + if self.landed and self.boosting: + self.landed = False + self._py -= 5 + self.physics_update(dt) + self._prev_rect = self.rect.copy() + self._px += self.velocity.x * dt * PIXELS_PER_METER + self._py -= self.velocity.y * dt * PIXELS_PER_METER self.update_image() - np = self.rect.move(self.velocity.x, -1 * self.velocity.y) - self.rect = np self.dirty = True - - def explode(self, screen): - for i in range(random.randint(20,40)): - pygame.draw.line(screen, - (random.randint(190, 255), - random.randint(0,100), - random.randint(0,100)), - self.rect.center, - (random.randint(0, SCREEN_WIDTH), - random.randint(0, SCREEN_HEIGHT)), - random.randint(1,3)) + + # -- misc --------------------------------------------------------------- + + def explode(self, surface): + """Draw a cartoon explosion onto ``surface`` at the craft's position.""" + cx, cy = self.rect.center + for _ in range(random.randint(20, 40)): + pygame.draw.line( + surface, + (random.randint(190, 255), random.randint(0, 100), random.randint(0, 100)), + (cx, cy), + (random.randint(0, SCREEN_WIDTH), random.randint(0, SCREEN_HEIGHT)), + random.randint(1, 3), + ) def stats(self): - return "Position: [%.2d,%.2d] Velocity: %.2f m/s at %.3d degrees Orientation: %.3d degrees Fuel: %d Status: [%s]" % (self.rect.top, self.rect.left, self.velocity.magnitude, self.velocity.angle, self.orientation, self.fuel, ("Crashed" if not self.intact else ("Landed" if self.landed else ("OK to Land" if self.ok_to_land() else "Not OK")))) + """Return a one-line summary of the craft's current state.""" + if not self.intact: + status = "Crashed" + elif self.landed: + status = "Landed" + elif self.ok_to_land(): + status = "OK to Land" + else: + status = "Not OK" + return ( + "Vel: %.2f m/s Orient: %d deg Fuel: %d kg Mass: %d kg Status: %s" + % ( + self.velocity.magnitude, + int(self.orientation), + int(round(self.fuel)), + int(round(self.mass)), + status, + ) + ) class Moon(pygame.sprite.DirtySprite): + """A cratered lunar surface running along the bottom of the screen. + + The whole flat top edge is safe to land on; the marked pad in the + middle is where a *perfect* landing happens. + """ + def __init__(self): - self.width = SCREEN_WIDTH+20 - self.height = 20 - self.image = pygame.Surface((self.width, self.height)) - self.rect = pygame.Rect(-10, SCREEN_HEIGHT - 20, SCREEN_WIDTH + 20, 20) + super().__init__() + self.width = SCREEN_WIDTH + 20 + self.height = MOON_HEIGHT + self.rect = pygame.Rect(-10, SCREEN_HEIGHT - MOON_HEIGHT, self.width, self.height) + self.image = make_moon_surface() + self.pad_rect = pygame.Rect( + SCREEN_WIDTH // 2 - PAD_WIDTH // 2, + SCREEN_HEIGHT - MOON_HEIGHT, + PAD_WIDTH, + MOON_HEIGHT, + ) self.landing_ok = True - return super(pygame.sprite.DirtySprite, self).__init__() class Boulder(pygame.sprite.DirtySprite): - def __init__(self): - self.diameter = random.randint(2, 300) - self.radius = self.diameter / 2 - self.x_pos = random.randint(0, SCREEN_WIDTH) - self.image = pygame.Surface((self.diameter, self.diameter)) - #self.image.fill((255,255,255,128)) - pygame.draw.circle(self.image, (128,128,128), (self.radius, self.radius), self.radius) - self.rect = pygame.Rect(self.x_pos, SCREEN_HEIGHT - (20 + self.radius), - self.diameter, self.diameter) - self.image = self.image.convert() + """An irregular rock scattered on the lunar surface. + + Touching a boulder always destroys the craft (``landing_ok`` is + ``False``). The surface uses per-pixel alpha and is procedurally + shaded so it reads as a real rock rather than a flat circle. + """ + + def __init__(self, rng=None, x_pos=None): + super().__init__() + rng = rng if rng is not None else random + self.diameter = rng.randint(BOULDER_MIN, BOULDER_MAX) + self.radius = self.diameter // 2 + self.x_pos = x_pos if x_pos is not None else rng.randint(0, SCREEN_WIDTH) + self.image = make_boulder_image(self.diameter, rng) + self.rect = self.image.get_rect(midbottom=(self.x_pos, SCREEN_HEIGHT - MOON_HEIGHT)) self.landing_ok = False - self.dirty = False - return super(pygame.sprite.DirtySprite, self).__init__() - -def initialize(): + +# --------------------------------------------------------------------------- +# World initialisation / game loop +# --------------------------------------------------------------------------- + +def initialize(): + """Create a fresh lander, moon, boulders and the sprite group for them. + + Boulders are kept clear of the landing pad so a perfect landing stays + possible. The moon is added first so it renders behind the lander. + """ lander = Lander() moon = Moon() - sprites = [lander] - boulders = [Boulder() for i in range(random.randint(2,5))] - sprites.extend(boulders) - sprites.append(moon) - return lander, moon, boulders, pygame.sprite.RenderPlain(sprites) + boulders = [Boulder(x_pos=random_boulder_x()) for _ in range(random.randint(2, 4))] + sprites = pygame.sprite.RenderPlain([moon] + boulders + [lander]) + return lander, moon, boulders, sprites -if __name__ == '__main__': +class LunarLander: + """Top-level game object: owns the screen, the sprites and the loop. - lander, moon, boulders, allsprites = initialize() + Runs a small state machine: - while True: - - pygame.event.pump() - keys = pygame.key.get_pressed() - - for event in pygame.event.get(): - - if event.type == QUIT: - pygame.quit() - sys.exit() - - if keys[K_r]: - lander, moon, boulders, allsprites = initialize() - elif keys[K_SPACE] or keys[K_UP]: - lander.boost() - elif keys[K_LEFT]: - lander.rotate(-5) - elif keys[K_RIGHT]: - lander.rotate(5) - - lander.check_landed(moon) - for boulder in boulders: - lander.check_landed(boulder) - - surface.fill((255,255,255)) - - font = pygame.font.Font(None, 14) - - text = font.render(lander.stats(), 1, (10, 10, 10)) - textpos = text.get_rect() - textpos.centerx = SCREEN_WIDTH / 2 - surface.blit(text, textpos) - screen.blit(surface, (0,0)) - allsprites.update() - allsprites.draw(screen) - - def render_center_text(surface, screen, txt, color): - font2 = pygame.font.Font(None, 36) - text = font2.render(txt, 1, color) - textpos = text.get_rect() - textpos.centerx = SCREEN_WIDTH / 2 - textpos.centery = SCREEN_HEIGHT / 2 - surface.blit(text, textpos) - screen.blit(surface, (0,0)) - - if lander.landed: - if not lander.intact: - lander.explode(screen) - #render_center_text(surface, screen, "Kaboom! Your craft is destroyed.", (255,0,0)) - else: - render_center_text(surface, screen, "You landed successfully!", (0,255,0)) + * ``"ready"`` -- the craft floats at the top; press SPACE to launch. + * ``"playing"`` -- physics and input are live. + * ``"result"`` -- the touchdown has been resolved; shown for + ``RESET_DELAY`` seconds, then the world is rebuilt in ``"ready"``. + ``step`` advances the simulation by a single frame and is kept separate + from rendering so the game can be driven headlessly in tests. + """ + + def __init__(self, surface=None, fps=FPS): + self.fps = fps + self.width, self.height = SCREEN_WIDTH, SCREEN_HEIGHT + if surface is None: + surface = pygame.display.set_mode((self.width, self.height)) + self.screen = surface + self.clock = pygame.time.Clock() + self.font_small = pygame.font.Font(None, 14) + self.font_large = pygame.font.Font(None, 36) + self._sky = make_sky() + self.running = True + self.state = "ready" + self.reset_timer = 0.0 + self._explosion = None + self._perfect = False + self.reset() + + def reset(self): + """Restart the world and return to the ready screen.""" + self.lander, self.moon, self.boulders, self.sprites = initialize() + self.state = "ready" + self.reset_timer = 0.0 + self._explosion = None + self._perfect = False + + def handle_input(self, keys, dt): + """Translate a list of pressed key constants into game actions.""" + if K_r in keys: + self.reset() + if self.state == "ready": + if K_SPACE in keys or K_UP in keys: + self.state = "playing" + return + if self.state == "result": + return + thrusting = K_SPACE in keys or K_UP in keys + self.lander.set_boosting(thrusting) + if K_LEFT in keys: + self.lander.rotate(-ROTATION_SPEED * dt) + if K_RIGHT in keys: + self.lander.rotate(ROTATION_SPEED * dt) + + def step(self, keys=None, events=None, dt=DEFAULT_DT): + """Advance the simulation by ``dt`` seconds. + + ``keys`` is an iterable of pressed key constants (e.g. built from + ``pygame.key.get_pressed()``); ``events`` is a list of pygame events. + """ + dt = min(dt, MAX_DT) + if events: + for event in events: + if event.type == QUIT: + self.running = False + if not self.running: + return + if keys is not None: + self.handle_input(keys, dt) + + if self.state == "playing": + self.lander.check_landed(self.moon) + for boulder in self.boulders: + self.lander.check_landed(boulder) + self.sprites.update(dt) + if self.lander.landed: + self.state = "result" + self._perfect = self.moon.pad_rect.collidepoint( + self.lander.rect.centerx, self.lander.rect.bottom + ) + elif self.state == "result": + self.reset_timer += dt + if self.reset_timer >= RESET_DELAY: + self.reset() + + # -- rendering ---------------------------------------------------------- + + def draw(self): + """Render one frame to the screen.""" + self.screen.blit(self._sky, (0, 0)) + self._draw_stats() + self._draw_hud() + self.sprites.draw(self.screen) + + if self.state == "ready": + self.render_center_text("Press SPACE to launch", (200, 210, 255)) + self.render_below("SPACE/UP: thrust LEFT/RIGHT: rotate R: restart", (160, 170, 210)) + elif self.state == "result": + if not self.lander.intact: + if self._explosion is None: + self._explosion = pygame.Surface((self.width, self.height)) + self.lander.explode(self._explosion) + self.screen.blit(self._explosion, (0, 0)) + self.render_center_text("Kaboom! Your craft is destroyed.", (255, 120, 120)) + else: + message = "Perfect landing!" if self._perfect else "You landed safely!" + self.render_center_text(message, (150, 255, 150)) + + def _draw_stats(self): + """Draw the detailed status line across the top of the screen.""" + text = self.font_small.render(self.lander.stats(), 1, (220, 225, 240)) + textpos = text.get_rect(centerx=self.width // 2) + self.screen.blit(text, textpos) + + def _draw_hud(self): + """Draw the fuel bar (top-left) and altitude/speed (top-right).""" + margin = 12 + bar_w, bar_h = 140, 10 + ratio = max(0.0, min(1.0, self.lander.fuel / FUEL_MASS)) + pygame.draw.rect(self.screen, (20, 22, 40), (margin, margin, bar_w, bar_h + 18)) + pygame.draw.rect(self.screen, (90, 95, 110), (margin + 2, margin + 2, bar_w - 4, bar_h)) + color = (120, 255, 150) if ratio > 0.25 else (255, 140, 80) + pygame.draw.rect(self.screen, color, (margin + 2, margin + 2, int((bar_w - 4) * ratio), bar_h)) + label = self.font_small.render("FUEL kg", 1, (200, 210, 230)) + self.screen.blit(label, (margin + 2, margin + bar_h + 6)) + + altitude_m = max(0, self.moon.rect.top - self.lander.rect.bottom) / PIXELS_PER_METER + hud = self.font_small.render( + "ALT %.0f m SPD %.2f m/s" % (altitude_m, self.lander.velocity.magnitude), + 1, + (200, 210, 230), + ) + self.screen.blit(hud, (self.width - hud.get_width() - margin, margin)) + + def render_center_text(self, txt, color): + """Blit ``txt`` centred on the screen.""" + text = self.font_large.render(txt, 1, color) + textpos = text.get_rect(center=(self.width // 2, self.height // 2)) + self.screen.blit(text, textpos) + + def render_below(self, txt, color): + """Blit small ``txt`` just below the screen centre.""" + text = self.font_small.render(txt, 1, color) + textpos = text.get_rect(center=(self.width // 2, self.height // 2 + 40)) + self.screen.blit(text, textpos) + + def run(self): + """Run the main game loop until the window is closed.""" + while self.running: + dt = self.clock.tick(self.fps) / 1000.0 + events = pygame.event.get() + pressed = pygame.key.get_pressed() + keys = pressed_keys(pressed) + self.step(keys=keys, events=events, dt=dt) + if not self.running: + break + self.draw() pygame.display.flip() - pygame.display.update() - time.sleep(1) - lander, moon, boulders, allsprites = initialize() - else: - pygame.display.flip() - pygame.display.update() - fpsClock.tick(FPS) # and tick the clock. + +def main(): + """Entry point: initialise PyGame, open a window and play.""" + pygame.init() + try: + screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) + pygame.display.set_caption("PyGame Lunar Lander") + LunarLander(surface=screen).run() + finally: + pygame.quit() + + +if __name__ == "__main__": + main() diff --git a/lunarlander/__init__.py b/lunarlander/__init__.py new file mode 100644 index 0000000..aafb3d0 --- /dev/null +++ b/lunarlander/__init__.py @@ -0,0 +1,79 @@ +"""PyGame Lunar Lander: a realistic lunar landing simulation. + +Re-exports the public API so both ``import lunarlander`` and +``from lunarlander import Lander`` work directly from the package. +""" + +from .assets import load_image +from .constants import ( + BOULDER_MAX, + BOULDER_MIN, + DEFAULT_DT, + DRY_MASS, + ENGINE_THRUST, + EXHAUST_VELOCITY, + FPS, + FUEL_BURN_RATE, + FUEL_MASS, + GRAVITY, + LANDING_ORIENTATION_TOLERANCE, + LANDING_VELOCITY_LIMIT, + MAX_DT, + MOON_HEIGHT, + PAD_WIDTH, + PIXELS_PER_METER, + POLLED_KEYS, + RESET_DELAY, + ROTATION_SPEED, + SCREEN_HEIGHT, + SCREEN_WIDTH, + pressed_keys, +) +from .game import LunarLander, initialize, main +from .sprites import Lander +from .terrain import ( + Boulder, + Moon, + make_boulder_image, + make_moon_surface, + make_sky, + random_boulder_x, +) +from .vectors import V + +__all__ = [ + "BOULDER_MAX", + "BOULDER_MIN", + "Boulder", + "DEFAULT_DT", + "DRY_MASS", + "ENGINE_THRUST", + "EXHAUST_VELOCITY", + "FPS", + "FUEL_BURN_RATE", + "FUEL_MASS", + "GRAVITY", + "LANDING_ORIENTATION_TOLERANCE", + "LANDING_VELOCITY_LIMIT", + "Lander", + "LunarLander", + "MAX_DT", + "MOON_HEIGHT", + "Moon", + "PAD_WIDTH", + "PIXELS_PER_METER", + "POLLED_KEYS", + "RESET_DELAY", + "ROTATION_SPEED", + "SCREEN_HEIGHT", + "SCREEN_WIDTH", + "V", + "initialize", + "load_image", + "main", + "make_boulder_image", + "make_moon_surface", + "make_sky", + "pressed_keys", + "random_boulder_x", +] diff --git a/lunarlander/__main__.py b/lunarlander/__main__.py new file mode 100644 index 0000000..b391bf3 --- /dev/null +++ b/lunarlander/__main__.py @@ -0,0 +1,6 @@ +"""Allow ``python -m lunarlander`` to launch the game.""" + +from .game import main + +if __name__ == "__main__": + main() diff --git a/lunarlander/assets.py b/lunarlander/assets.py new file mode 100644 index 0000000..87fb0f4 --- /dev/null +++ b/lunarlander/assets.py @@ -0,0 +1,37 @@ +"""Asset loading helpers.""" + +import os +from typing import Optional, Tuple + +import pygame +from pygame.locals import RLEACCEL + + +def _asset_path(name: str) -> str: + """Return the absolute path of an image inside the bundled assets dir.""" + return os.path.join(os.path.dirname(os.path.abspath(__file__)), name) + + +def load_image(name: str, colorkey: Optional[int] = None) -> Tuple[pygame.Surface, pygame.Rect]: + """Load an image, optionally applying a transparent colorkey. + + Returns a ``(surface, rect)`` tuple. Images with per-pixel alpha are + converted with ``convert_alpha``; everything else is converted to the + display format (fast blitting) and, when a colorkey is given, made + transparent with it. Raises ``FileNotFoundError`` if the asset cannot + be found. + """ + fullname = _asset_path(name) + try: + image = pygame.image.load(fullname) + except pygame.error as exc: + raise FileNotFoundError("Cannot load image: {}".format(fullname)) from exc + if image.get_flags() & pygame.SRCALPHA: + image = image.convert_alpha() + else: + image = image.convert() + if colorkey is not None: + if colorkey == -1: + colorkey = image.get_at((0, 0)) + image.set_colorkey(colorkey, RLEACCEL) + return image, image.get_rect() diff --git a/lunarlander/constants.py b/lunarlander/constants.py new file mode 100644 index 0000000..fc70271 --- /dev/null +++ b/lunarlander/constants.py @@ -0,0 +1,53 @@ +"""Tunable game parameters and the keys polled by the input loop.""" + +from pygame.locals import K_LEFT, K_RIGHT, K_SPACE, K_UP, K_r + +# -- timing / window --------------------------------------------------------- + +FPS = 80 +SCREEN_WIDTH, SCREEN_HEIGHT = 800, 600 + +# -- physics (real-world SI units) ------------------------------------------- + +# The Moon's surface gravity is used and the lander's mass falls as fuel is +# burnt, so thrust-to-weight ratio (and therefore acceleration) rises over +# time, exactly like a real rocket. +GRAVITY = 1.62 # Moon surface gravity, m/s^2 +PIXELS_PER_METER = 8.0 # world-to-screen scale +DRY_MASS = 1500.0 # kg, empty lander +FUEL_MASS = 450.0 # kg, full tank +FUEL_BURN_RATE = 4.0 # kg/s propellant flow rate +EXHAUST_VELOCITY = 2500.0 # m/s effective exhaust velocity +ENGINE_THRUST = FUEL_BURN_RATE * EXHAUST_VELOCITY # N (= 10 kN) +LANDING_VELOCITY_LIMIT = 3.0 # max touchdown speed, m/s + +ROTATION_SPEED = 40.0 # degrees/s while a rotation key is held +LANDING_ORIENTATION_TOLERANCE = 10 # max deviation from upright, degrees +RESET_DELAY = 2.0 # seconds the result screen is shown +MAX_DT = 0.05 # clamp for slow frames, seconds + +# Default integration step used when no real clock is available (tests). +DEFAULT_DT = 1.0 / FPS + +# -- terrain ---------------------------------------------------------------- + +MOON_HEIGHT = 90 # visible height of the lunar surface strip +PAD_WIDTH = 150 # width of the landing pad +BOULDER_MIN, BOULDER_MAX = 22, 64 + +# -- input ------------------------------------------------------------------ + +# Keys whose state is polled each frame; indices into the sequence returned by +# pygame.key.get_pressed(). +POLLED_KEYS = (K_r, K_SPACE, K_UP, K_LEFT, K_RIGHT) + + +def pressed_keys(pressed): + """Map the sequence from ``pygame.key.get_pressed()`` to pressed key + constants. + + ``get_pressed()`` returns a scancode-indexed sequence, so its raw indices + cannot be compared with the ``K_*`` constants; the wrapper's ``__getitem__`` + performs that translation for us. + """ + return [key for key in POLLED_KEYS if pressed[key]] diff --git a/lunarlander/game.py b/lunarlander/game.py new file mode 100644 index 0000000..5406dc7 --- /dev/null +++ b/lunarlander/game.py @@ -0,0 +1,216 @@ +"""The top-level game object and entry point.""" + +import random +from typing import Iterable, List, Optional, Tuple + +import pygame +from pygame.locals import K_LEFT, K_RIGHT, K_SPACE, K_UP, QUIT, K_r + +from .constants import ( + DEFAULT_DT, + FPS, + FUEL_MASS, + MAX_DT, + PIXELS_PER_METER, + RESET_DELAY, + ROTATION_SPEED, + SCREEN_HEIGHT, + SCREEN_WIDTH, + pressed_keys, +) +from .sprites import Lander +from .terrain import Boulder, Moon, make_sky, random_boulder_x + + +def initialize() -> Tuple[Lander, Moon, List[Boulder], pygame.sprite.Group]: + """Create a fresh lander, moon, boulders and the sprite group for them. + + Boulders are kept clear of the landing pad so a perfect landing stays + possible. The moon is added first so it renders behind the lander. + """ + lander = Lander() + moon = Moon() + boulders = [Boulder(x_pos=random_boulder_x()) for _ in range(random.randint(2, 4))] + sprites = pygame.sprite.RenderPlain([moon] + boulders + [lander]) + return lander, moon, boulders, sprites + + +class LunarLander: + """Top-level game object: owns the screen, the sprites and the loop. + + Runs a small state machine: + + * ``"ready"`` -- the craft floats at the top; press SPACE to launch. + * ``"playing"`` -- physics and input are live. + * ``"result"`` -- the touchdown has been resolved; shown for + ``RESET_DELAY`` seconds, then the world is rebuilt in ``"ready"``. + + ``step`` advances the simulation by a single frame and is kept separate + from rendering so the game can be driven headlessly in tests. + """ + + def __init__(self, surface: Optional[pygame.Surface] = None, fps: int = FPS) -> None: + self.fps = fps + self.width, self.height = SCREEN_WIDTH, SCREEN_HEIGHT + if surface is None: + surface = pygame.display.set_mode((self.width, self.height)) + self.screen = surface + self.clock = pygame.time.Clock() + self.font_small = pygame.font.Font(None, 14) + self.font_large = pygame.font.Font(None, 36) + self._sky = make_sky() + self.running = True + self.state = "ready" + self.reset_timer = 0.0 + self._explosion = None + self._perfect = False + self.reset() + + def reset(self) -> None: + """Restart the world and return to the ready screen.""" + self.lander, self.moon, self.boulders, self.sprites = initialize() + self.state = "ready" + self.reset_timer = 0.0 + self._explosion = None + self._perfect = False + + def handle_input(self, keys: Iterable[int], dt: float) -> None: + """Translate a list of pressed key constants into game actions.""" + if K_r in keys: + self.reset() + if self.state == "ready": + if K_SPACE in keys or K_UP in keys: + self.state = "playing" + return + if self.state == "result": + return + thrusting = K_SPACE in keys or K_UP in keys + self.lander.set_boosting(thrusting) + if K_LEFT in keys: + self.lander.rotate(-ROTATION_SPEED * dt) + if K_RIGHT in keys: + self.lander.rotate(ROTATION_SPEED * dt) + + def step( + self, + keys: Optional[Iterable[int]] = None, + events: Optional[list] = None, + dt: float = DEFAULT_DT, + ) -> None: + """Advance the simulation by ``dt`` seconds. + + ``keys`` is an iterable of pressed key constants (e.g. built from + ``pygame.key.get_pressed()``); ``events`` is a list of pygame events. + """ + dt = min(dt, MAX_DT) + if events: + for event in events: + if event.type == QUIT: + self.running = False + if not self.running: + return + if keys is not None: + self.handle_input(keys, dt) + + if self.state == "playing": + self.lander.check_landed(self.moon) + for boulder in self.boulders: + self.lander.check_landed(boulder) + self.sprites.update(dt) + if self.lander.landed: + self.state = "result" + self._perfect = self.moon.pad_rect.collidepoint( + self.lander.rect.centerx, self.lander.rect.bottom + ) + elif self.state == "result": + self.reset_timer += dt + if self.reset_timer >= RESET_DELAY: + self.reset() + + # -- rendering ---------------------------------------------------------- + + def draw(self) -> None: + """Render one frame to the screen.""" + self.screen.blit(self._sky, (0, 0)) + self._draw_stats() + self._draw_hud() + self.sprites.draw(self.screen) + + if self.state == "ready": + self.render_center_text("Press SPACE to launch", (200, 210, 255)) + self.render_below( + "SPACE/UP: thrust LEFT/RIGHT: rotate R: restart", (160, 170, 210) + ) + elif self.state == "result": + if not self.lander.intact: + if self._explosion is None: + self._explosion = pygame.Surface((self.width, self.height)) + self.lander.explode(self._explosion) + self.screen.blit(self._explosion, (0, 0)) + self.render_center_text("Kaboom! Your craft is destroyed.", (255, 120, 120)) + else: + message = "Perfect landing!" if self._perfect else "You landed safely!" + self.render_center_text(message, (150, 255, 150)) + + def _draw_stats(self) -> None: + """Draw the detailed status line across the top of the screen.""" + text = self.font_small.render(self.lander.stats(), 1, (220, 225, 240)) + textpos = text.get_rect(centerx=self.width // 2) + self.screen.blit(text, textpos) + + def _draw_hud(self) -> None: + """Draw the fuel bar (top-left) and altitude/speed (top-right).""" + margin = 12 + bar_w, bar_h = 140, 10 + ratio = max(0.0, min(1.0, self.lander.fuel / FUEL_MASS)) + pygame.draw.rect(self.screen, (20, 22, 40), (margin, margin, bar_w, bar_h + 18)) + pygame.draw.rect(self.screen, (90, 95, 110), (margin + 2, margin + 2, bar_w - 4, bar_h)) + color = (120, 255, 150) if ratio > 0.25 else (255, 140, 80) + width = int((bar_w - 4) * ratio) + pygame.draw.rect(self.screen, color, (margin + 2, margin + 2, width, bar_h)) + label = self.font_small.render("FUEL kg", 1, (200, 210, 230)) + self.screen.blit(label, (margin + 2, margin + bar_h + 6)) + + altitude_m = max(0, self.moon.rect.top - self.lander.rect.bottom) / PIXELS_PER_METER + hud = self.font_small.render( + "ALT %.0f m SPD %.2f m/s" % (altitude_m, self.lander.velocity.magnitude), + 1, + (200, 210, 230), + ) + self.screen.blit(hud, (self.width - hud.get_width() - margin, margin)) + + def render_center_text(self, txt: str, color) -> None: + """Blit ``txt`` centred on the screen.""" + text = self.font_large.render(txt, 1, color) + textpos = text.get_rect(center=(self.width // 2, self.height // 2)) + self.screen.blit(text, textpos) + + def render_below(self, txt: str, color) -> None: + """Blit small ``txt`` just below the screen centre.""" + text = self.font_small.render(txt, 1, color) + textpos = text.get_rect(center=(self.width // 2, self.height // 2 + 40)) + self.screen.blit(text, textpos) + + def run(self) -> None: + """Run the main game loop until the window is closed.""" + while self.running: + dt = self.clock.tick(self.fps) / 1000.0 + events = pygame.event.get() + pressed = pygame.key.get_pressed() + keys = pressed_keys(pressed) + self.step(keys=keys, events=events, dt=dt) + if not self.running: + break + self.draw() + pygame.display.flip() + + +def main() -> None: + """Entry point: initialise PyGame, open a window and play.""" + pygame.init() + try: + screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) + pygame.display.set_caption("PyGame Lunar Lander") + LunarLander(surface=screen).run() + finally: + pygame.quit() diff --git a/lunarlander/sprites.py b/lunarlander/sprites.py new file mode 100644 index 0000000..1bc58aa --- /dev/null +++ b/lunarlander/sprites.py @@ -0,0 +1,197 @@ +"""The player-controlled lander sprite.""" + +import random +from typing import Optional + +import pygame + +from .assets import load_image +from .constants import ( + DRY_MASS, + EXHAUST_VELOCITY, + FUEL_BURN_RATE, + FUEL_MASS, + GRAVITY, + LANDING_ORIENTATION_TOLERANCE, + LANDING_VELOCITY_LIMIT, + PIXELS_PER_METER, + SCREEN_HEIGHT, + SCREEN_WIDTH, +) +from .vectors import V + + +class Lander(pygame.sprite.DirtySprite): + """The player-controlled lunar lander. + + Handles its own physics (gravity, continuous thrust), rotation, fuel, + landing detection and the engine flame. Rotated frames are cached so + the expensive ``pygame.transform.rotate`` call only runs when the + orientation actually changes. + """ + + def __init__( + self, + image: Optional[pygame.Surface] = None, + flame_image: Optional[pygame.Surface] = None, + ) -> None: + super().__init__() + if image is None: + image, _ = load_image("lander.jpg", -1) + if flame_image is None: + flame_image, _ = load_image("lander_flame.jpg", -1) + + self.original = image + self.original_flame = flame_image + self.image = image + self.rect = image.get_rect(topleft=(SCREEN_WIDTH // 2, 20)) + + self.orientation = 0.0 + self.velocity = V() # metres/second + self.landed = False + self.intact = True + self.fuel = FUEL_MASS # kilograms of propellant + self.boosting = False + + # Position is tracked in float so sub-pixel motion accumulates; + # PyGame rects are integer-only and would swallow small steps. + self._px, self._py = float(self.rect.centerx), float(self.rect.centery) + self._prev_rect = self.rect.copy() + self._rotation_cache = {} + + # -- helpers ------------------------------------------------------------ + + def _sync_center(self) -> None: + """Re-read the float position from the (integer) rect after a direct move.""" + self._px, self._py = float(self.rect.centerx), float(self.rect.centery) + + def _rotated(self, img: pygame.Surface, angle: float) -> pygame.Surface: + """Return ``img`` rotated by ``angle`` degrees, caching the result.""" + key = (id(img), int(round(angle))) + rotated = self._rotation_cache.get(key) + if rotated is None: + rotated = pygame.transform.rotate(img, angle) + self._rotation_cache[key] = rotated + return rotated + + def update_image(self) -> None: + """Refresh the sprite image from the current orientation/engine state.""" + img = self.original_flame if self.boosting else self.original + rotated = self._rotated(img, -1 * self.orientation) + self.image = rotated + self.rect = rotated.get_rect(center=(round(self._px), round(self._py))) + + def set_boosting(self, active: bool) -> None: + """Turn the engine on/off; it only fires while fuel remains.""" + self.boosting = active and self.fuel > 0 + + def rotate(self, angle: float) -> None: + """Rotate the craft by ``angle`` degrees, keeping orientation in [0, 360).""" + self.orientation = (self.orientation + angle) % 360.0 + + @property + def mass(self) -> float: + """Current lander mass: dry mass plus remaining fuel, in kg.""" + return DRY_MASS + self.fuel + + def physics_update(self, dt: float) -> None: + """Integrate one physics step of ``dt`` seconds. + + While the engine is on it burns up to ``FUEL_BURN_RATE`` kg/s of fuel. + If the tank empties mid-step, the fuel that *was* burnt still delivers + its full impulse for the step: thrust is ``burned / dt * exhaust + velocity``, so partial steps get partial (not zero) thrust. + Coasting falls at the Moon's gravity. + """ + if self.landed: + return + if self.boosting: + burn = min(FUEL_BURN_RATE * dt, self.fuel) + thrust = burn / dt * EXHAUST_VELOCITY + self.fuel -= burn + accel = thrust / self.mass - GRAVITY + self.velocity += V(magnitude=accel * dt, angle=self.orientation) + if self.fuel <= 0: + self.boosting = False + else: + self.velocity += V(magnitude=GRAVITY * dt, angle=180) + + def ok_to_land(self) -> bool: + """True when the craft is nearly upright and moving slowly enough.""" + orientation = self.orientation % 360.0 + upright = ( + orientation < LANDING_ORIENTATION_TOLERANCE + or orientation > 360.0 - LANDING_ORIENTATION_TOLERANCE + ) + return upright and self.velocity.magnitude < LANDING_VELOCITY_LIMIT + + def check_landed(self, surface) -> None: + """Resolve a touchdown against ``surface`` (a ``Moon`` or ``Boulder``). + + Prevents tunnelling: if the craft crossed the surface's top edge + since the previous frame, it is snapped back on top instead of + sinking through it. + """ + if self.landed: + return + if hasattr(surface, "radius"): + collision = pygame.sprite.collide_circle(self, surface) + else: + collision = pygame.sprite.collide_rect(self, surface) + if not collision: + return + if surface.landing_ok and self._prev_rect.bottom <= surface.rect.top <= self.rect.bottom: + self.rect.bottom = surface.rect.top + self.landed = True + self.intact = self.ok_to_land() and surface.landing_ok + self.velocity = V() + self._sync_center() + + # -- per-frame update --------------------------------------------------- + + def update(self, dt: float) -> None: + """Advance the craft by ``dt`` seconds: physics, animation, movement.""" + if self.landed and self.boosting: + self.landed = False + self._py -= 5 + self.physics_update(dt) + self._prev_rect = self.rect.copy() + self._px += self.velocity.x * dt * PIXELS_PER_METER + self._py -= self.velocity.y * dt * PIXELS_PER_METER + self.update_image() + self.dirty = True + + # -- misc --------------------------------------------------------------- + + def explode(self, surface) -> None: + """Draw a cartoon explosion onto ``surface`` at the craft's position.""" + cx, cy = self.rect.center + for _ in range(random.randint(20, 40)): + pygame.draw.line( + surface, + (random.randint(190, 255), random.randint(0, 100), random.randint(0, 100)), + (cx, cy), + (random.randint(0, SCREEN_WIDTH), random.randint(0, SCREEN_HEIGHT)), + random.randint(1, 3), + ) + + def stats(self) -> str: + """Return a one-line summary of the craft's current state.""" + if not self.intact: + status = "Crashed" + elif self.landed: + status = "Landed" + elif self.ok_to_land(): + status = "OK to Land" + else: + status = "Not OK" + return ( + "Vel: %.2f m/s Orient: %d deg Fuel: %d kg Mass: %d kg Status: %s" + % ( + self.velocity.magnitude, + int(self.orientation), + int(round(self.fuel)), + int(round(self.mass)), + status, + ) + ) diff --git a/lunarlander/terrain.py b/lunarlander/terrain.py new file mode 100644 index 0000000..167f3e2 --- /dev/null +++ b/lunarlander/terrain.py @@ -0,0 +1,180 @@ +"""Procedurally drawn lunar surface and its obstacles.""" + +import math +import random +from typing import Optional + +import pygame + +from .constants import ( + BOULDER_MAX, + BOULDER_MIN, + MOON_HEIGHT, + PAD_WIDTH, + SCREEN_HEIGHT, + SCREEN_WIDTH, +) + + +def make_sky() -> pygame.Surface: + """Generate the static starfield + planet background once.""" + sky = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)) + rng = random.Random() + horizon = SCREEN_HEIGHT - MOON_HEIGHT + + top = (6, 8, 26) + bottom = (24, 30, 60) + for y in range(SCREEN_HEIGHT): + t = y / SCREEN_HEIGHT + color = tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)) + pygame.draw.line(sky, color, (0, y), (SCREEN_WIDTH, y)) + + # Soft glow above the horizon. + for y in range(max(0, horizon - 45), horizon): + t = (y - (horizon - 45)) / 45.0 + glow = int(36 + 44 * t) + pygame.draw.line(sky, (glow, glow, glow), (0, y), (SCREEN_WIDTH, y)) + + # Stars. + for _ in range(180): + x = rng.randrange(0, SCREEN_WIDTH) + y = rng.randrange(0, horizon) + if rng.random() < 0.7: + sky.set_at((x, y), rng.choice([(255, 255, 255), (200, 210, 255), (255, 230, 200)])) + else: + pygame.draw.circle(sky, rng.choice([(255, 255, 255), (180, 200, 255)]), (x, y), 1) + + # A distant planet, top-right. + planet = pygame.Surface((130, 130), pygame.SRCALPHA) + pygame.draw.circle(planet, (60, 110, 180), (65, 65), 62) + for px, py, pr in [(48, 48, 24), (80, 58, 20), (55, 84, 28)]: + pygame.draw.circle(planet, (80, 150, 205), (px, py), pr) + pygame.draw.circle(planet, (200, 230, 255), (40, 36), 58, 2) + sky.blit(planet, (SCREEN_WIDTH - 150, 20)) + return sky + + +def make_moon_surface() -> pygame.Surface: + """Generate a cratered lunar surface strip with a landing pad.""" + w = SCREEN_WIDTH + 20 + h = MOON_HEIGHT + rng = random.Random() + moon = pygame.Surface((w, h)) + + # Vertical gradient: darker at the horizon, lighter towards the bottom. + top = (148, 148, 156) + bottom = (205, 205, 212) + for y in range(h): + t = y / h + color = tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)) + moon.fill(color, (0, y, w, 1)) + + # Speckle noise for texture. + for _ in range(1200): + x = rng.randrange(0, w) + y = rng.randrange(0, h) + d = rng.choice((110, 125, 140, 160, 185, 210)) + moon.set_at((x, y), (d, d, d)) + + # Craters: dark bowl, mid-grey floor and a lit rim on the sun side. + for _ in range(rng.randint(5, 9)): + cx = rng.randrange(25, w - 25) + cy = rng.randrange(16, h - 6) + r = rng.randrange(6, 20) + pygame.draw.circle(moon, (108, 108, 116), (cx, cy), r) + pygame.draw.circle(moon, (138, 138, 146), (cx, cy), r - 2) + pygame.draw.circle(moon, (228, 228, 235), (cx - 2, cy - 2), max(1, r - 4), 1) + + _draw_pad(moon, w, h) + return moon + + +def _draw_pad(moon: pygame.Surface, w: int, h: int) -> None: + """Paint a clearly marked landing pad on the surface strip.""" + pad_w = PAD_WIDTH + x0 = SCREEN_WIDTH // 2 - pad_w // 2 + pad = pygame.Surface((pad_w, h), pygame.SRCALPHA) + pad.fill((215, 210, 185, 130)) + + # Target concentric to the landing surface. + cx, cy = pad_w // 2, 16 + for radius in (6, 12, 18): + pygame.draw.circle(pad, (90, 85, 70, 200), (cx, cy), radius, 2) + pygame.draw.circle(pad, (90, 85, 70, 200), (cx, cy), 2) + + # Dashed edge markers down each side of the pad. + for yy in range(4, h, 8): + pygame.draw.line(pad, (120, 115, 95, 200), (2, yy), (6, yy)) + pygame.draw.line(pad, (120, 115, 95, 200), (pad_w - 6, yy), (pad_w - 2, yy)) + + moon.blit(pad, (x0, 0)) + + +def make_boulder_image(size: int, rng) -> pygame.Surface: + """Generate an irregular, shaded rocky boulder as an RGBA surface.""" + surf = pygame.Surface((size, size), pygame.SRCALPHA) + c = size // 2 + points = [] + for i in range(9): + a = 2 * math.pi * i / 9 + r = size * 0.5 * rng.uniform(0.70, 1.05) + points.append((c + r * math.cos(a), c + r * math.sin(a))) + + pygame.draw.polygon(surf, (140, 137, 132), points) + pygame.draw.polygon(surf, (96, 93, 88), points, 2) + highlight = [(x - 1, y - 1) for (x, y) in points] + pygame.draw.polygon(surf, (180, 177, 172), highlight, 1) + for _ in range(12): + x = c + rng.randrange(-size // 3, size // 3) + y = c + rng.randrange(-size // 3, size // 3) + surf.set_at((x, y), (95, 95, 95)) + return surf + + +def random_boulder_x(rng=random) -> int: + """Return a surface x-position outside the landing pad.""" + while True: + x = rng.randint(0, SCREEN_WIDTH) + if abs(x - SCREEN_WIDTH // 2) > PAD_WIDTH // 2 + 45: + return x + + +class Moon(pygame.sprite.DirtySprite): + """A cratered lunar surface running along the bottom of the screen. + + The whole flat top edge is safe to land on; the marked pad in the + middle is where a *perfect* landing happens. + """ + + def __init__(self) -> None: + super().__init__() + self.width = SCREEN_WIDTH + 20 + self.height = MOON_HEIGHT + self.rect = pygame.Rect(-10, SCREEN_HEIGHT - MOON_HEIGHT, self.width, self.height) + self.image = make_moon_surface() + self.pad_rect = pygame.Rect( + SCREEN_WIDTH // 2 - PAD_WIDTH // 2, + SCREEN_HEIGHT - MOON_HEIGHT, + PAD_WIDTH, + MOON_HEIGHT, + ) + self.landing_ok = True + + +class Boulder(pygame.sprite.DirtySprite): + """An irregular rock scattered on the lunar surface. + + Touching a boulder always destroys the craft (``landing_ok`` is + ``False``). The surface uses per-pixel alpha and is procedurally + shaded so it reads as a real rock rather than a flat circle. + """ + + def __init__(self, rng=None, x_pos: Optional[int] = None) -> None: + super().__init__() + rng = rng if rng is not None else random + self.diameter = rng.randint(BOULDER_MIN, BOULDER_MAX) + self.radius = self.diameter // 2 + self.x_pos = x_pos if x_pos is not None else rng.randint(0, SCREEN_WIDTH) + self.image = make_boulder_image(self.diameter, rng) + self.rect = self.image.get_rect(midbottom=(self.x_pos, SCREEN_HEIGHT - MOON_HEIGHT)) + self.landing_ok = False diff --git a/lunarlander/vectors.py b/lunarlander/vectors.py new file mode 100644 index 0000000..eb41eb1 --- /dev/null +++ b/lunarlander/vectors.py @@ -0,0 +1,62 @@ +"""A minimal 2D vector type used by the physics simulation.""" + +import math +from typing import Optional + + +class V: + """A minimal 2D vector. + + Supports initialisation from Cartesian (``x``, ``y``) or polar + (``angle``, ``magnitude``) form. Angles are measured in degrees, + clockwise from "up", matching the screen-space movement rules of the + lander: angle 0 means straight up and 90 means to the right. + """ + + __slots__ = ("x", "y") + + def __init__( + self, + x: float = 0.0, + y: float = 0.0, + angle: Optional[float] = None, + magnitude: Optional[float] = None, + ) -> None: + self.x = float(x) + self.y = float(y) + if angle is not None and magnitude is not None: + rad = math.radians(angle) + self.x = magnitude * math.sin(rad) + self.y = magnitude * math.cos(rad) + + @property + def magnitude(self) -> float: + """Length of the vector.""" + return math.hypot(self.x, self.y) + + @property + def angle(self) -> float: + """Direction in degrees clockwise from up, normalised to [0, 360).""" + return math.degrees(math.atan2(self.x, self.y)) % 360.0 + + def __add__(self, other: "V") -> "V": + return V(self.x + other.x, self.y + other.y) + + def __iadd__(self, other: "V") -> "V": + self.x += other.x + self.y += other.y + return self + + def rotate(self, angle: float) -> None: + """Rotate the vector clockwise by ``angle`` degrees, in place.""" + rad = math.radians(angle) + c, s = math.cos(rad), math.sin(rad) + x, y = self.x, self.y + self.x = x * c - y * s + self.y = x * s + y * c + + def __eq__(self, other: "V") -> bool: + return math.isclose(self.x, other.x) and math.isclose(self.y, other.y) + + def __repr__(self) -> str: + return "V(x={:.2f}, y={:.2f})".format(self.x, self.y) diff --git a/main.py b/main.py new file mode 100644 index 0000000..cc568de --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +"""Convenience launcher: run the game from the project root.""" + +from lunarlander import main + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7be60a6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "pygame-lunarlander" +version = "2.0.0" +description = "A realistic lunar landing simulation built with PyGame." +readme = "README.md" +requires-python = ">=3.9" +keywords = ["pygame", "game", "lunar lander", "simulation"] +dependencies = ["pygame>=2.1"] + +[project.optional-dependencies] +dev = ["pytest>=7.0", "ruff>=0.4"] + +[project.scripts] +lunar-lander = "lunarlander:main" + +[tool.setuptools] +packages = ["lunarlander"] + +[tool.setuptools.package-data] +lunarlander = ["*.jpg"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py39" +line-length = 100 +# main.py is a local convenience launcher, not part of the packaged/linted code. +extend-exclude = ["main.py"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4272baf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pygame>=2.1 +pytest>=7.0 diff --git a/snake.py b/snake.py deleted file mode 100644 index edbacf9..0000000 --- a/snake.py +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env python - -import pygame -import sys -import time -import random - -from pygame.locals import * - -FPS = 15 -pygame.init() -fpsClock=pygame.time.Clock() - -SCREEN_WIDTH, SCREEN_HEIGHT = 640, 480 -screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), 0, 32) -surface = pygame.Surface(screen.get_size()) -surface = surface.convert() -surface.fill((255,255,255)) -clock = pygame.time.Clock() - -pygame.key.set_repeat(1, 40) - -GRIDSIZE=10 -GRID_WIDTH = SCREEN_WIDTH / GRIDSIZE -GRID_HEIGHT = SCREEN_HEIGHT / GRIDSIZE -UP = (0, -1) -DOWN = (0, 1) -LEFT = (-1, 0) -RIGHT = (1, 0) - -screen.blit(surface, (0,0)) - -def draw_box(surf, color, pos): - r = pygame.Rect((pos[0], pos[1]), (GRIDSIZE, GRIDSIZE)) - pygame.draw.rect(surf, color, r) - -class Snake(object): - def __init__(self): - self.lose() - self.color = (0,0,0) - - def get_head_position(self): - return self.positions[0] - - def lose(self): - self.length = 1 - self.positions = [((SCREEN_WIDTH / 2), (SCREEN_HEIGHT / 2))] - self.direction = random.choice([UP, DOWN, LEFT, RIGHT]) - - def point(self, pt): - if self.length > 1 and (pt[0] * -1, pt[1] * -1) == self.direction: - return - else: - self.direction = pt - - def move(self): - cur = self.positions[0] - x, y = self.direction - new = (((cur[0]+(x*GRIDSIZE)) % SCREEN_WIDTH), (cur[1]+(y*GRIDSIZE)) % SCREEN_HEIGHT) - if len(self.positions) > 2 and new in self.positions[2:]: - self.lose() - else: - self.positions.insert(0, new) - if len(self.positions) > self.length: - self.positions.pop() - - def draw(self, surf): - for p in self.positions: - draw_box(surf, self.color, p) - -class Apple(object): - def __init__(self): - self.position = (0,0) - self.color = (255,0,0) - self.randomize() - - def randomize(self): - self.position = (random.randint(0, GRID_WIDTH-1) * GRIDSIZE, random.randint(0, GRID_HEIGHT-1) * GRIDSIZE) - - def draw(self, surf): - draw_box(surf, self.color, self.position) - -def check_eat(snake, apple): - if snake.get_head_position() == apple.position: - snake.length += 1 - apple.randomize() - - -class Rock (object): - def __init__(self): - self.position = (0,0) - self.color = (160,80,30) - self.randomize() - - def randomize(self): - self.position = (random.randint(0, GRID_WIDTH-1) * GRIDSIZE, random.randint(0, GRID_HEIGHT-1) * GRIDSIZE) - - def draw(self, surf): - draw_box(surf, self.color, self.position) - -def check_smash(snake, rock): - if snake.get_head_position() == rock.position: - snake.lose() - - - -if __name__ == '__main__': - snake = Snake() - apple = Apple() - rock = Rock() - while True: - - for event in pygame.event.get(): - if event.type == QUIT: - pygame.quit() - sys.exit() - elif event.type == KEYDOWN: - if event.key == K_UP: - snake.point(UP) - elif event.key == K_DOWN: - snake.point(DOWN) - elif event.key == K_LEFT: - snake.point(LEFT) - elif event.key == K_RIGHT: - snake.point(RIGHT) - - - surface.fill((255,255,255)) - snake.move() - check_eat(snake, apple) - check_smash(snake, rock) - snake.draw(surface) - apple.draw(surface) - rock.draw(surface) - font = pygame.font.Font(None, 36) - text = font.render(str(snake.length), 1, (10, 10, 10)) - textpos = text.get_rect() - textpos.centerx = 20 - surface.blit(text, textpos) - screen.blit(surface, (0,0)) - - pygame.display.flip() - pygame.display.update() - fpsClock.tick(FPS + snake.length/3) diff --git a/snake2.py b/snake2.py deleted file mode 100644 index bb8baa5..0000000 --- a/snake2.py +++ /dev/null @@ -1,226 +0,0 @@ -import pygame -import sys -import time -import random - -from pygame.locals import * - -snap_time = 0 -#rainbow_berry_effects = 0 -FPS = 15 -pygame.init() -fpsClock=pygame.time.Clock() - -SCREEN_WIDTH, SCREEN_HEIGHT = 800, 800 -screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), 0, 32) -surface = pygame.Surface(screen.get_size()) -surface = surface.convert() -surface.fill((255,255,255)) -clock = pygame.time.Clock() - -pygame.key.set_repeat(1, 40) - -GRIDSIZE=10 -GRID_WIDTH = SCREEN_WIDTH / GRIDSIZE -GRID_HEIGHT = SCREEN_HEIGHT / GRIDSIZE -UP = (0, -1) -DOWN = (0, 1) -LEFT = (-1, 0) -RIGHT = (1, 0) -BERRY_TYPES = 5 - -screen.blit(surface, (0,0)) - - -def draw_box(surf, color, pos): - r = pygame.Rect((pos[0], pos[1]), (GRIDSIZE, GRIDSIZE)) - pygame.draw.rect(surf, color, r) - -class Snake(object): - def __init__(self): - self.lose() - self.color = (0,0,0) - self.snap_time = 0 - - - def get_head_position(self): - return self.positions[0] - - def lose(self): - print('You have lost. The game will restart shortly. Press "q" to quit') - time.sleep(2.5) - self.length = 1 - self.positions = [((SCREEN_WIDTH / 2), (SCREEN_HEIGHT / 2))] - self.direction = random.choice([UP, DOWN, LEFT, RIGHT]) - - def point(self, pt): - if self.length > 1 and (pt[0] * -1, pt[1] * -1) == self.direction: - return - else: - self.direction = pt - - def move(self): - cur = self.positions[0] - x, y = self.direction - # if timer expires, set in_boost_snap to false - cur_speed_time = time.time() - if cur_speed_time - self.snap_time >= 5: - speed = 1 - # print("timer is off " + str(cur_time) + " " + str(self.snap_time)) - self.snap_time = 0 - else: - speed = 2 - # print("timer is on " + str(cur_time) + str(self.snap_time)) - - new = (((cur[0]+(x*speed*GRIDSIZE)) % SCREEN_WIDTH), (cur[1]+(y*speed*GRIDSIZE)) % SCREEN_HEIGHT) - if len(self.positions) > 2 and new in self.positions[2:]: - self.lose() - else: - self.positions.insert(0, new) - if len(self.positions) > self.length: - self.positions.pop() - - def draw(self, surf): - for p in self.positions: - draw_box(surf, self.color, p) - -class Apple(object): - def __init__(self): - self.position = (0,0) - self.color = (255,0,0) - self.randomize() - - def randomize(self): - self.position = (random.randint(0, GRID_WIDTH-1) * GRIDSIZE, random.randint(0, GRID_HEIGHT-1) * GRIDSIZE) - - def draw(self, surf): - draw_box(surf, self.color, self.position) - -def check_eat_apple(snake, apple): - if snake.get_head_position() == apple.position: - snake.length += 1 - apple.randomize() - -class Blueberry(object): - def __init__(self): - self.position = (0,0) - self.color = (0,0,255) - self.randomize() - - def randomize(self): - self.position = (random.randint(0, GRID_WIDTH-1) * GRIDSIZE, random.randint(0, GRID_HEIGHT-1) * GRIDSIZE) - - def draw(self, surf): - draw_box(surf, self.color, self.position) - -def check_eat_blueberry(snake, Blueberry): - if snake.get_head_position() == blueberry.position: - #start timer for 5-10 seconds - snake.snap_time = time.time() - print("""You have eaten a blueberry. -Your speed will be doubled for the next 5 seconds. -_______________________________________________________""") - time.sleep(1) - snake.length += 5 - Blueberry.randomize() - - - - -class Rock (object): - def __init__(self): - self.position = (0,0) - self.color = (160,80,30) - self.randomize() - - def randomize(self): - self.position = (random.randint(0, GRID_WIDTH-1) * GRIDSIZE, - random.randint(0, GRID_HEIGHT-1) * GRIDSIZE) - - def draw(self, surf): - draw_box(surf, self.color, self.position) - -def check_smash_rock(snake, rock): - if snake.get_head_position() == rock.position: - snake.lose() - - -class Thorns (object): - def __init__(self): - self.position = (0,0) - self.color = (50,135,50) - self.randomize() - - def randomize(self): - self.position = (random.randint(0, GRID_WIDTH-1) * GRIDSIZE, - random.randint(0, GRID_HEIGHT-1) * GRIDSIZE) - - def draw(self, surf): - draw_box(surf, self.color, self.position) - -def check_smash_thorns(snake, thorns): - if snake.get_head_position() == thorns.position: - snake.lose() - - - - - - -if __name__ == '__main__': - #snake - snake = Snake() - - #fruits - apple = Apple() - blueberry = Blueberry() - #obstacles - rock = Rock() - thorns = Thorns() - - while True: - for event in pygame.event.get(): - if event.type == QUIT: - pygame.quit() - sys.exit() - elif event.type == KEYDOWN: - if event.key == K_UP or event.key == K_w: - snake.point(UP) - elif event.key == K_DOWN or event.key == K_s: - snake.point(DOWN) - elif event.key == K_LEFT or event.key == K_a: - snake.point(LEFT) - elif event.key == K_RIGHT or event.key == K_d: - snake.point(RIGHT) - elif event.key == K_q: - sys.exit() - - - surface.fill((255,255,255)) - snake.move() - - #checking for collision - check_eat_apple(snake, apple) - check_eat_blueberry(snake, blueberry) - check_smash_thorns(snake, thorns) - check_smash_rock(snake, rock) - - #drawing everything - snake.draw(surface) - apple.draw(surface) - blueberry.draw(surface) - rock.draw(surface) - thorns.draw(surface) - - #displaying the score - font = pygame.font.Font(None, 36) - text = font.render(str(snake.length), 1, (10, 10, 10)) - textpos = text.get_rect() - textpos.centerx = 20 - surface.blit(text, textpos) - screen.blit(surface, (0,0)) - - #updating the screen - pygame.display.flip() - pygame.display.update() - fpsClock.tick(FPS + snake.length/3) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_lunarlander.py b/tests/test_lunarlander.py new file mode 100644 index 0000000..cf6d38a --- /dev/null +++ b/tests/test_lunarlander.py @@ -0,0 +1,441 @@ +"""Unit tests for the PyGame Lunar Lander. + +The suite runs headlessly: ``conftest.py`` sets the SDL dummy drivers and +initialises PyGame before these tests are collected. +""" + +import os +import sys + +import pygame +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import lunarlander as ll +from lunarlander import Boulder, Lander, Moon, V + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def lander(): + return Lander() + + +@pytest.fixture() +def moon(): + return Moon() + + +@pytest.fixture() +def surface(): + screen = pygame.display.get_surface() + if screen is None: + screen = pygame.display.set_mode((ll.SCREEN_WIDTH, ll.SCREEN_HEIGHT)) + return screen + + +@pytest.fixture() +def game(surface): + return ll.LunarLander(surface=surface) + + +# --------------------------------------------------------------------------- +# Vector math +# --------------------------------------------------------------------------- + +def test_vector_defaults(): + v = V() + assert v.x == 0.0 + assert v.y == 0.0 + + +def test_vector_from_polar_up(): + v = V(magnitude=5, angle=0) + assert v.x == pytest.approx(0.0, abs=1e-9) + assert v.y == pytest.approx(5.0) + + +def test_vector_from_polar_right(): + v = V(magnitude=5, angle=90) + assert v.x == pytest.approx(5.0) + assert v.y == pytest.approx(0.0, abs=1e-9) + + +def test_vector_magnitude(): + assert V(3, 4).magnitude == pytest.approx(5.0) + + +def test_vector_angle_cardinals(): + assert V(0, 5).angle == pytest.approx(0.0) + assert V(5, 0).angle == pytest.approx(90.0) + assert V(0, -5).angle == pytest.approx(180.0) + assert V(-5, 0).angle == pytest.approx(270.0) + + +def test_vector_polar_roundtrip(): + assert V(magnitude=10, angle=30).angle == pytest.approx(30.0) + + +def test_vector_rotate_right_then_up(): + v = V(5, 0) + v.rotate(90) + assert v.x == pytest.approx(0.0, abs=1e-6) + assert v.y == pytest.approx(5.0, abs=1e-6) + + +def test_vector_rotate_up_then_left(): + v = V(0, 5) + v.rotate(90) + assert v.x == pytest.approx(-5.0, abs=1e-6) + assert v.y == pytest.approx(0.0, abs=1e-6) + + +def test_vector_add(): + total = V(1, 2) + V(3, 4) + assert total.x == 4.0 + assert total.y == 6.0 + + +def test_vector_iadd(): + v = V(1, 1) + v += V(2, 3) + assert v == V(3, 4) + + +def test_vector_equality(): + assert V(1, 2) == V(1, 2) + assert V(1, 2) != V(2, 1) + + +# --------------------------------------------------------------------------- +# Lander physics +# --------------------------------------------------------------------------- + +def test_gravity_pulls_lander_down(lander): + before = lander.velocity.y + lander.physics_update(ll.DEFAULT_DT) + assert lander.velocity.y == pytest.approx(before - ll.GRAVITY * ll.DEFAULT_DT) + + +def test_gravity_skipped_when_landed(lander): + lander.landed = True + before = lander.velocity.y + lander.physics_update(ll.DEFAULT_DT) + assert lander.velocity.y == before + + +def test_mass_includes_fuel(lander): + assert lander.mass == pytest.approx(ll.DRY_MASS + lander.fuel) + lander.fuel = 0 + assert lander.mass == pytest.approx(ll.DRY_MASS) + + +def test_thrust_burns_fuel_and_speeds_up(lander): + initial_fuel = lander.fuel + initial_speed = lander.velocity.magnitude + lander.set_boosting(True) + lander.update(ll.DEFAULT_DT) + assert lander.fuel == pytest.approx(initial_fuel - ll.FUEL_BURN_RATE * ll.DEFAULT_DT) + assert lander.fuel < initial_fuel + assert lander.velocity.magnitude > initial_speed + assert lander.boosting is True + + +def test_engine_out_is_noop(lander): + lander.fuel = 0 + lander.set_boosting(True) + assert lander.boosting is False + lander.update(ll.DEFAULT_DT) + assert lander.fuel == 0 + # Only gravity acts once the tank is empty. + assert lander.velocity.y == pytest.approx(-ll.GRAVITY * ll.DEFAULT_DT) + + +def test_mid_frame_fuel_exhaustion_still_thrusts(lander): + """Fuel exhausted mid-step must still deliver the partial impulse. + + The tank holds enough for half the frame; that half-burn must produce + thrust for the whole step rather than being discarded, so the velocity + change is thrust-minus-gravity, not gravity alone. + """ + burn = ll.FUEL_BURN_RATE * ll.DEFAULT_DT * 0.5 + lander.fuel = burn + lander.set_boosting(True) + assert lander.boosting is True + lander.update(ll.DEFAULT_DT) + assert lander.fuel == pytest.approx(0.0, abs=1e-9) + assert lander.boosting is False + thrust = burn / ll.DEFAULT_DT * ll.EXHAUST_VELOCITY + assert thrust / ll.DRY_MASS > ll.GRAVITY # partial burn beats gravity + accel = thrust / ll.DRY_MASS - ll.GRAVITY + assert lander.velocity.y == pytest.approx(accel * ll.DEFAULT_DT) + + +def test_thrust_acceleration_rises_as_fuel_burns(lander): + lander.set_boosting(True) + lander.update(ll.DEFAULT_DT) + accel_full = lander.velocity.magnitude / ll.DEFAULT_DT + accel_empty = ll.ENGINE_THRUST / ll.DRY_MASS - ll.GRAVITY + assert accel_empty > accel_full + + +def test_thrust_lifts_off_when_landed(lander): + lander.landed = True + lander._py = 400 + lander.set_boosting(True) + lander.update(ll.DEFAULT_DT) + assert not lander.landed + assert lander._py < 400 + + +def test_ok_to_land_when_upright_and_slow(lander): + lander.velocity = V(0, -1.0) + assert lander.ok_to_land() + + +def test_ok_to_land_rejects_negative_orientation(lander): + lander.orientation = -20 + assert not lander.ok_to_land() + + +def test_ok_to_land_rejects_tilted(lander): + lander.orientation = 45 + lander.velocity = V(0, -1.0) + assert not lander.ok_to_land() + + +def test_ok_to_land_rejects_fast(lander): + lander.velocity = V(0, -ll.LANDING_VELOCITY_LIMIT * 2) + assert not lander.ok_to_land() + + +def test_orientation_is_normalised(lander): + lander.rotate(720) + assert lander.orientation == pytest.approx(0.0) + lander.rotate(-15) + assert lander.orientation == pytest.approx(345.0) + + +# --------------------------------------------------------------------------- +# Landing / collision +# --------------------------------------------------------------------------- + +def test_safe_landing(lander, moon): + lander.velocity = V(0, -1.5) + lander.rect.bottom = moon.rect.top + 5 + lander.check_landed(moon) + assert lander.landed + assert lander.intact + assert lander.velocity == V() + assert lander.rect.bottom == moon.rect.top + + +def test_hard_landing_crashes(lander, moon): + lander.velocity = V(0, -ll.LANDING_VELOCITY_LIMIT * 3) + lander.rect.bottom = moon.rect.top + 5 + lander.check_landed(moon) + assert lander.landed + assert not lander.intact + + +def test_tilted_landing_crashes(lander, moon): + lander.orientation = 45 + lander.velocity = V(0, -1.0) + lander.rect.bottom = moon.rect.top + 5 + lander.check_landed(moon) + assert lander.landed + assert not lander.intact + + +def test_boulder_landing_crashes(lander): + boulder = Boulder() + lander.rect.center = boulder.rect.center + lander.velocity = V(0, -1.0) + lander.check_landed(boulder) + assert lander.landed + assert not lander.intact + + +def test_lander_does_not_tunnel_through_moon(lander, moon): + lander._prev_rect = lander.rect.copy() + lander._prev_rect.bottom = moon.rect.top - 5 + lander.rect.bottom = moon.rect.top + 30 + lander.velocity = V(0, -ll.LANDING_VELOCITY_LIMIT * 3) + lander.check_landed(moon) + assert lander.landed + assert lander.rect.bottom == moon.rect.top + + +def test_check_landed_ignores_remote_surfaces(lander, moon): + lander.velocity = V(0, -1.0) + lander.check_landed(moon) + assert not lander.landed + + +# --------------------------------------------------------------------------- +# Moon / Boulder +# --------------------------------------------------------------------------- + +def test_moon_is_safe_to_land_on(moon): + assert moon.landing_ok is True + + +def test_boulder_is_not_safe_to_land_on(): + assert Boulder().landing_ok is False + + +def test_boulder_has_transparent_background(): + assert Boulder().image.get_flags() & pygame.SRCALPHA + + +def test_boulders_sit_on_top_of_moon(): + moon = Moon() + for _ in range(50): + boulder = Boulder() + assert boulder.rect.bottom == moon.rect.top + + +def test_moon_has_landing_pad(): + moon = Moon() + assert moon.pad_rect.width == ll.PAD_WIDTH + assert moon.pad_rect.centerx == ll.SCREEN_WIDTH // 2 + assert moon.pad_rect.top == moon.rect.top + + +def test_boulders_avoid_landing_pad(): + for _ in range(200): + x = ll.random_boulder_x() + assert abs(x - ll.SCREEN_WIDTH // 2) > ll.PAD_WIDTH // 2 + 45 + + +def test_sky_and_moon_surfaces_build(): + assert ll.make_sky().get_size() == (ll.SCREEN_WIDTH, ll.SCREEN_HEIGHT) + assert ll.make_moon_surface().get_height() == ll.MOON_HEIGHT + + +# --------------------------------------------------------------------------- +# Game states (headless) +# --------------------------------------------------------------------------- + +def test_game_starts_in_ready_state(game): + assert game.state == "ready" + + +def test_ready_state_holds_lander(game): + for _ in range(30): + game.step(keys=[], dt=ll.DEFAULT_DT) + assert game.lander.rect.top == 20 + assert game.state == "ready" + + +def test_launch_starts_playing(game): + game.handle_input([pygame.K_SPACE], ll.DEFAULT_DT) + assert game.state == "playing" + + +def test_pressed_keys_maps_scancodes_to_constants(): + """Regression test: get_pressed() is scancode-indexed, not K_* indexed. + + SDL scancode 44 is the space bar and 80 is the left arrow; those raw + indices must be translated to K_SPACE / K_LEFT by pressed_keys(). + """ + seq = bytearray(512) + seq[44] = 1 # space + seq[80] = 1 # left arrow + pressed = pygame.key.ScancodeWrapper(bytes(seq)) + keys = ll.pressed_keys(pressed) + assert pygame.K_SPACE in keys + assert pygame.K_LEFT in keys + assert pygame.K_r not in keys + assert pygame.K_UP not in keys + assert pygame.K_RIGHT not in keys + + +def test_game_steps_and_draws_while_playing(game): + game.handle_input([pygame.K_SPACE], ll.DEFAULT_DT) + for _ in range(60): + game.step(keys=[], dt=ll.DEFAULT_DT) + game.draw() + assert game.lander.rect.top > 20 # gravity has moved the craft + + +def test_handle_input_boost_while_playing(game): + game.handle_input([pygame.K_SPACE], ll.DEFAULT_DT) # launch + fuel = game.lander.fuel + speed = game.lander.velocity.magnitude + game.step(keys=[pygame.K_SPACE], dt=ll.DEFAULT_DT) # thrust for one frame + assert game.lander.fuel < fuel + assert game.lander.velocity.magnitude > speed + assert game.lander.boosting is True + + +def test_boosting_turns_off_when_key_released(game): + game.handle_input([pygame.K_SPACE], ll.DEFAULT_DT) # launch + game.step(keys=[pygame.K_SPACE], dt=ll.DEFAULT_DT) # thrust for one frame + assert game.lander.boosting is True + game.step(keys=[], dt=ll.DEFAULT_DT) # release the key + assert game.lander.boosting is False + + +def test_handle_input_restart(game): + game.lander.fuel = 0 + game.handle_input([pygame.K_r], ll.DEFAULT_DT) + assert game.lander.fuel == ll.FUEL_MASS + assert game.state == "ready" + + +def test_quit_event_stops_game(game): + game.step(events=[pygame.event.Event(pygame.QUIT)]) + assert not game.running + + +def test_auto_reset_after_landing(game): + game.handle_input([pygame.K_SPACE], ll.DEFAULT_DT) # launch + game.lander.rect.bottom = game.moon.rect.top + 5 + game.lander.velocity = V(0, -40) + game.lander.check_landed(game.moon) + assert game.lander.landed + steps = int(ll.RESET_DELAY / ll.DEFAULT_DT) + 2 + for _ in range(steps): + game.step(keys=[], dt=ll.DEFAULT_DT) + assert game.state == "ready" + assert game.lander.rect.top == 20 + + +def test_perfect_landing_detected(game): + game.handle_input([pygame.K_SPACE], ll.DEFAULT_DT) # launch + game.lander.rect.centerx = game.moon.pad_rect.centerx + game.lander.rect.bottom = game.moon.rect.top + 5 + game.lander.velocity = V(0, -40) + game.step(keys=[], dt=ll.DEFAULT_DT) + assert game.state == "result" + assert game._perfect is True + + +def test_full_fall_eventually_lands(game): + game.handle_input([pygame.K_SPACE], ll.DEFAULT_DT) # launch + landed = False + for _ in range(1200): + game.step(keys=[], dt=ll.DEFAULT_DT) + if game.lander.landed: + landed = True + break + assert landed + assert game.lander.rect.bottom <= game.moon.rect.top + + +# --------------------------------------------------------------------------- +# Assets +# --------------------------------------------------------------------------- + +def test_load_image_missing_raises(): + with pytest.raises(FileNotFoundError): + ll.load_image("does_not_exist.jpg") + + +def test_load_image_returns_surface_and_rect(): + image, rect = ll.load_image("lander.jpg", -1) + assert image.get_size() == rect.size