diff --git a/tetris/block.py b/tetris/block.py new file mode 100644 index 0000000..7055dfc --- /dev/null +++ b/tetris/block.py @@ -0,0 +1,10 @@ +class Block: + """A Block represents a single square on the Tetris board. + Blocks are part of a Piece while they are 'alive'. + """ + character = "X" + color = "blue" + alive = True + + def __init__(self, position): + self.position = position diff --git a/tetris/game.py b/tetris/game.py new file mode 100644 index 0000000..32cab79 --- /dev/null +++ b/tetris/game.py @@ -0,0 +1,7 @@ +from retro.game import Game +from manager import Manager + +agents = [Manager()] +state = {'level': 1} +game = Game(agents, state, board_size=(20, 20), debug=True) +game.play() diff --git a/tetris/manager.py b/tetris/manager.py new file mode 100644 index 0000000..6629dfd --- /dev/null +++ b/tetris/manager.py @@ -0,0 +1,24 @@ +from piece import Piece, PIECE_DEFINITIONS +from random import choice +from retro.errors import AgentNotFoundByName + +class Manager: + """The Manager takes care of stuff that isn't anyone else's responsibility: + - Create a Piece whenever none exists. + - Clear full rows of Blocks (and move other Blocks down). + - End the game when the Blocks pile up all the way. + """ + display = False + + def play_turn(self, game): + try: + game.get_agent_by_name("piece") + except AgentNotFoundByName: + self.create_piece(game) + + def create_piece(self, game): + width, height = game.board_size + piece = Piece((width//2, 2), game, choice(PIECE_DEFINITIONS)) + game.add_agent(piece) + + diff --git a/tetris/piece.py b/tetris/piece.py new file mode 100644 index 0000000..beddd0a --- /dev/null +++ b/tetris/piece.py @@ -0,0 +1,100 @@ +from block import Block + +PIECE_DEFINITIONS = [ + [(-1, 0), (0, 0), (1, 0), (2, 0)], + [(0, 0), (1, 0), (0, 1), (1, 1)], +] + +class Piece: + """A Piece is a group of blocks which are 'alive': + They fall and the player can rotate or move them. + A Piece is created with a position, the game, and a list of block_offsets, + each of which represents the location of one of the Piece's + Blocks relative to the Piece position. + """ + name = "piece" + display = False + + def __init__(self, position, game, block_offsets): + self.position = position + self.blocks = {} + for offset in block_offsets: + self.create_block(game, offset) + + def handle_keystroke(self, keystroke, game): + x, y = self.position + if keystroke.name == "KEY_LEFT": + new_position = (x - 1, y) + if self.can_move_to(new_position, game): + self.move_to(new_position) + elif keystroke.name == "KEY_RIGHT": + new_position = (x + 1, y) + if self.can_move_to(new_position, game): + self.move_to(new_position) + + def play_turn(self, game): + if self.should_fall(game): + self.fall(game) + + def should_fall(self, game): + """Determines whether the piece should fall. + Currently, the Piece falls every third turn. + In the future, the Piece should fall slowly at first, and + then should fall faster at higher levels. + """ + return game.turn_number % 3 == 0 + + def fall(self, game): + x, y = self.position + falling_position = (x, y + 1) + if self.can_move_to(falling_position, game): + self.move_to(falling_position) + else: + self.destroy(game) + + def can_move_to(self, new_position, game): + """Checks whether the Piece can move to a new position. + For every one of the Piece's Blocks, finds where that block + would be after the move, and checks whether there are any dead agents + already there (live agents would be Blocks which are part of this Piece, + not a problem since they'll be moving too). + """ + x, y = new_position + agents_by_position = game.get_agents_by_position() + for offset in self.blocks.keys(): + ox, oy = offset + new_block_position = (x+ox, y+oy) + if not game.on_board(new_block_position): + return False + for agent in agents_by_position[new_block_position]: + if not agent.alive: + return False + return True + + def move_to(self, position): + """Move to position and updates positions of Blocks. + """ + x, y = position + self.position = position + for offset, block in self.blocks.items(): + ox, oy = offset + block.position = (x + ox, y + oy) + + def create_block(self, game, offset): + x, y = self.position + ox, oy = offset + block = Block((x + ox, y + oy)) + self.blocks[offset] = block + game.add_agent(block) + + def destroy(self, game): + """Causes the Piece to destroy itself. + All the Blocks are set to dead. + """ + for block in self.blocks.values(): + block.alive = False + game.remove_agent(self) + + + + diff --git a/tetris/planning.jpg b/tetris/planning.jpg new file mode 100644 index 0000000..43bd614 Binary files /dev/null and b/tetris/planning.jpg differ diff --git a/tetris/poetry.lock b/tetris/poetry.lock new file mode 100644 index 0000000..83da853 --- /dev/null +++ b/tetris/poetry.lock @@ -0,0 +1,91 @@ +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. + +[[package]] +name = "ansicon" +version = "1.89.0" +description = "Python wrapper for loading Jason Hood's ANSICON" +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec"}, + {file = "ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1"}, +] + +[[package]] +name = "blessed" +version = "1.20.0" +description = "Easy, practical library for making terminal apps, by providing an elegant, well-documented interface to Colors, Keyboard input, and screen Positioning capabilities." +optional = false +python-versions = ">=2.7" +groups = ["main"] +files = [ + {file = "blessed-1.20.0-py2.py3-none-any.whl", hash = "sha256:0c542922586a265e699188e52d5f5ac5ec0dd517e5a1041d90d2bbf23f906058"}, + {file = "blessed-1.20.0.tar.gz", hash = "sha256:2cdd67f8746e048f00df47a2880f4d6acbcdb399031b604e34ba8f71d5787680"}, +] + +[package.dependencies] +jinxed = {version = ">=1.1.0", markers = "platform_system == \"Windows\""} +six = ">=1.9.0" +wcwidth = ">=0.1.4" + +[[package]] +name = "jinxed" +version = "1.3.0" +description = "Jinxed Terminal Library" +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "jinxed-1.3.0-py2.py3-none-any.whl", hash = "sha256:b993189f39dc2d7504d802152671535b06d380b26d78070559551cbf92df4fc5"}, + {file = "jinxed-1.3.0.tar.gz", hash = "sha256:1593124b18a41b7a3da3b078471442e51dbad3d77b4d4f2b0c26ab6f7d660dbf"}, +] + +[package.dependencies] +ansicon = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "retro-games" +version = "1.1.3" +description = "A simple framework for Terminal-based games" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "retro_games-1.1.3-py3-none-any.whl", hash = "sha256:4bdd27241b5cb3ee72e69a042d301ff58df2a2ade7e3c29400a538fa54e30148"}, + {file = "retro_games-1.1.3.tar.gz", hash = "sha256:4f91ff725e551820aa4e30c12c0264e2da41967ed34252122b7136bc2a8ed311"}, +] + +[package.dependencies] +blessed = ">=1.20.0,<2.0.0" + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10,<4.0" +content-hash = "03cc38c17964eb2c920ecf014cbfcf966c0c719418a127947b33382f086a0a6e" diff --git a/tetris/proposal.md b/tetris/proposal.md new file mode 100644 index 0000000..9a64528 --- /dev/null +++ b/tetris/proposal.md @@ -0,0 +1,27 @@ +## Game name + +Tessera + +## Vision + +Tessera takes classic Tetris and adds a light puzzle layer: arranged clears that match colors grant small bonuses, rewarding planning without changing the tight, fast arcade feel. It's worth making because it blends a familiar, addictive core with a gentle twist that increases depth for skilled players while staying approachable. + +## What the game will look like + +A clean, modern pixel/flat aesthetic with bright, readable tetromino colors on a dark background; the HUD shows next pieces, hold slot, score, level, and subtle particle effects on clears. + +## Player interaction + +Players use keyboard controls to move, rotate, soft/hard drop, and hold pieces; menus use simple mouse/keyboard navigation. The game emphasizes quick decision-making and responsive controls with a visible ghost piece to aid placement. + +## Core mechanics + +1. Falling tetromino placement and rotation — move and rotate pieces to fit them into the well. +2. Line clearing and gravity — completed rows clear and the above rows shift down, possibly chaining combos. +3. Hold and next-queue — store one piece and preview upcoming pieces to plan ahead. +4. Color-match bonus (optional) — clearing lines where most blocks share a color grants small score/energy bonuses to encourage pattern play. + +## Milestone (first target) + +Implement the core game engine: represent the board and pieces, support spawning, movement, rotation, collision detection, locking, and single-line clearing so you can run a minimal playable loop and unit tests for the engine. + diff --git a/tetris/pyproject.toml b/tetris/pyproject.toml new file mode 100644 index 0000000..7ce89ea --- /dev/null +++ b/tetris/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "project-game" +version = "0.1.0" +description = "" +authors = [ + {name = "Chris Proctor",email = "chris@chrisproctor.net"} +] +license = {text = "MIT"} +readme = "README.md" +requires-python = ">=3.10,<4.0" +dependencies = [ + "retro-games (>=1.1.0,<2.0.0)" +] + + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +package-mode = false