commit 85be45e2b8f56eedf6f89d242a465491e2b3d280 Author: Lukas Nöllemeyer Date: Fri Aug 16 17:20:07 2024 +0200 first commit diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..9acbe7f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Module", + "type": "python", + "request": "launch", + "module": "main", + "justMyCode": true + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..deb6640 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,24 @@ +{ + "files.trimTrailingWhitespace": true, + "files.trimFinalNewlines": true, + "files.insertFinalNewline": true, + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": "always" + }, + "editor.rulers": [ + 120 + ], + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "cSpell.words": [ + "blit", + "isort", + "PAGEDOWN", + "PAGEUP", + "tcod", + "tileset", + "tilesheet" + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/README.md b/README.md new file mode 100644 index 0000000..2339b4e --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# python-tcod-tutorial-2023 diff --git a/data/Alloy_curses_12x12.png b/data/Alloy_curses_12x12.png new file mode 100644 index 0000000..29662df Binary files /dev/null and b/data/Alloy_curses_12x12.png differ diff --git a/g.py b/g.py new file mode 100644 index 0000000..66440ab --- /dev/null +++ b/g.py @@ -0,0 +1,21 @@ +"""This module stores globally mutable variables used by this program.""" + +from __future__ import annotations + +import tcod.console +import tcod.context +import tcod.ecs + +import game.state + +context: tcod.context.Context +"""The window managed by tcod.""" + +world: tcod.ecs.Registry +"""The active ECS registry and current session.""" + +states: list[game.state.State] = [] +"""A stack of states with the last item being the active state.""" + +console: tcod.console.Console +"""The current main console.""" diff --git a/game/__init__.py b/game/__init__.py new file mode 100644 index 0000000..fe4e8e7 --- /dev/null +++ b/game/__init__.py @@ -0,0 +1 @@ +"""Game namespace package.""" diff --git a/game/components.py b/game/components.py new file mode 100644 index 0000000..1144f11 --- /dev/null +++ b/game/components.py @@ -0,0 +1,45 @@ +"""Collection of common components.""" + +from __future__ import annotations + +from typing import Final, Self + +import attrs +import tcod.ecs.callbacks +from tcod.ecs import Entity + + +@attrs.define(frozen=True) +class Position: + """An entities position.""" + + x: int + y: int + + def __add__(self, direction: tuple[int, int]) -> Self: + """Add a vector to this position.""" + x, y = direction + return self.__class__(self.x + x, self.y + y) + + +@tcod.ecs.callbacks.register_component_changed(component=Position) +def on_position_changed(entity: Entity, old: Position | None, new: Position | None) -> None: + """Mirror position components as a tag.""" + if old == new: + return + if old is not None: + entity.tags.discard(old) + if new is not None: + entity.tags.add(new) + + +@attrs.define(frozen=True) +class Graphic: + """An entities icon and color.""" + + ch: int = ord("!") + fg: tuple[int, int, int] = (255, 255, 255) + + +Gold: Final = ("Gold", int) +"""Amount of gold.""" diff --git a/game/constants.py b/game/constants.py new file mode 100644 index 0000000..3e9afcf --- /dev/null +++ b/game/constants.py @@ -0,0 +1,36 @@ +"""Global constants are stored here.""" + +from typing import Final + +from tcod.event import KeySym + +DIRECTION_KEYS: Final = { + # Arrow keys + KeySym.LEFT: (-1, 0), + KeySym.RIGHT: (1, 0), + KeySym.UP: (0, -1), + KeySym.DOWN: (0, 1), + # Arrow key diagonals + KeySym.HOME: (-1, -1), + KeySym.END: (-1, 1), + KeySym.PAGEUP: (1, -1), + KeySym.PAGEDOWN: (1, 1), + # Keypad + KeySym.KP_4: (-1, 0), + KeySym.KP_6: (1, 0), + KeySym.KP_8: (0, -1), + KeySym.KP_2: (0, 1), + KeySym.KP_7: (-1, -1), + KeySym.KP_1: (-1, 1), + KeySym.KP_9: (1, -1), + KeySym.KP_3: (1, 1), + # VI keys + KeySym.h: (-1, 0), + KeySym.l: (1, 0), + KeySym.k: (0, -1), + KeySym.j: (0, 1), + KeySym.y: (-1, -1), + KeySym.b: (-1, 1), + KeySym.u: (1, -1), + KeySym.n: (1, 1), +} diff --git a/game/menus.py b/game/menus.py new file mode 100644 index 0000000..7c1bb37 --- /dev/null +++ b/game/menus.py @@ -0,0 +1,101 @@ +"""Menu UI classes.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Protocol + +import attrs +import tcod.console +import tcod.event +from tcod.event import KeySym + +import game.state_tools +from game.constants import DIRECTION_KEYS +from game.state import Pop, State, StateResult + + +class MenuItem(Protocol): + """Menu item protocol.""" + + __slots__ = () + + def on_event(self, event: tcod.event.Event) -> StateResult: + """Handle events passed to the menu item.""" + + def on_draw(self, console: tcod.console.Console, x: int, y: int, highlight: bool) -> None: + """Draw is item at the given position.""" + + +@attrs.define() +class SelectItem(MenuItem): + """Clickable menu item.""" + + label: str + callback: Callable[[], StateResult] + + def on_event(self, event: tcod.event.Event) -> StateResult: + """Handle events selecting this item.""" + match event: + case tcod.event.KeyDown(sym=sym) if sym in {KeySym.RETURN, KeySym.RETURN2, KeySym.KP_ENTER}: + return self.callback() + case tcod.event.MouseButtonUp(button=tcod.event.MouseButton.LEFT): + return self.callback() + case _: + return None + + def on_draw(self, console: tcod.console.Console, x: int, y: int, highlight: bool) -> None: + """Render this items label.""" + console.print(x, y, self.label, fg=(255, 255, 255), bg=(64, 64, 64) if highlight else (0, 0, 0)) + + +@attrs.define() +class ListMenu(State): + """Simple list menu state.""" + + items: tuple[MenuItem, ...] + selected: int | None = 0 + x: int = 0 + y: int = 0 + + def on_event(self, event: tcod.event.Event) -> StateResult: + """Handle events for menus.""" + match event: + case tcod.event.Quit(): + raise SystemExit() + case tcod.event.KeyDown(sym=sym) if sym in DIRECTION_KEYS: + dx, dy = DIRECTION_KEYS[sym] + if dx != 0 or dy == 0: + return self.activate_selected(event) + if self.selected is not None: + self.selected += dy + self.selected %= len(self.items) + else: + self.selected = 0 if dy == 1 else len(self.items) - 1 + return None + case tcod.event.MouseMotion(position=(_, y)): + y -= self.y + self.selected = y if 0 <= y < len(self.items) else None + return None + case tcod.event.KeyDown(sym=KeySym.ESCAPE): + return self.on_cancel() + case tcod.event.MouseButtonUp(button=tcod.event.MouseButton.RIGHT): + return self.on_cancel() + case _: + return self.activate_selected(event) + + def activate_selected(self, event: tcod.event.Event) -> StateResult: + """Call the selected menu items callback.""" + if self.selected is not None: + return self.items[self.selected].on_event(event) + return None + + def on_cancel(self) -> StateResult: + """Handle escape or right click being pressed on menus.""" + return Pop() + + def on_draw(self, console: tcod.console.Console) -> None: + """Render the menu.""" + game.state_tools.draw_previous_state(self, console) + for i, item in enumerate(self.items): + item.on_draw(console, x=self.x, y=self.y + i, highlight=i == self.selected) diff --git a/game/state.py b/game/state.py new file mode 100644 index 0000000..4b935c4 --- /dev/null +++ b/game/state.py @@ -0,0 +1,44 @@ +"""Base classes for states.""" + +from __future__ import annotations + +from typing import Protocol, TypeAlias + +import attrs +import tcod.console +import tcod.event + + +class State(Protocol): + """An abstract game state.""" + + __slots__ = () + + def on_event(self, event: tcod.event.Event) -> StateResult: + """Called on events.""" + + def on_draw(self, console: tcod.console.Console) -> None: + """Called when the state is being drawn.""" + + +@attrs.define() +class Push: + """Push a new state on top of the stack.""" + + state: State + + +@attrs.define() +class Pop: + """Remove the current state from the stack.""" + + +@attrs.define() +class Reset: + """Replace the entire stack with a new state.""" + + state: State + + +StateResult: TypeAlias = "Push | Pop | Reset | None" +"""Union of state results.""" diff --git a/game/state_tools.py b/game/state_tools.py new file mode 100644 index 0000000..77d2e22 --- /dev/null +++ b/game/state_tools.py @@ -0,0 +1,61 @@ +"""State handling functions.""" + +from __future__ import annotations + +import tcod.console + +import g +from game.state import Pop, Push, Reset, State, StateResult + + +def main_draw() -> None: + """Render and present the active state.""" + if not g.states: + return + g.console.clear() + g.states[-1].on_draw(g.console) + g.context.present(g.console) + + +def apply_state_result(result: StateResult) -> None: + """Apply a StateResult to `g.states`.""" + match result: + case Push(state=state): + g.states.append(state) + case Pop(): + g.states.pop() + case Reset(state=state): + while g.states: + apply_state_result(Pop()) + apply_state_result(Push(state)) + case None: + pass + case _: + raise TypeError(result) + + +def main_loop() -> None: + """Run the active state forever.""" + while g.states: + main_draw() + for event in tcod.event.wait(): + tile_event = g.context.convert_event(event) + if g.states: + apply_state_result(g.states[-1].on_event(tile_event)) + + +def get_previous_state(state: State) -> State | None: + """Return the state before `state` in the stack if it exists.""" + current_index = next(index for index, value in enumerate(g.states) if value is state) + return g.states[current_index - 1] if current_index > 0 else None + + +def draw_previous_state(state: State, console: tcod.console.Console, dim: bool = True) -> None: + """Draw previous states, optionally dimming all but the active state.""" + prev_state = get_previous_state(state) + if prev_state is None: + return + prev_state.on_draw(console) + if dim and state is g.states[-1]: + console.rgb["fg"] //= 4 + console.rgb["bg"] //= 4 diff --git a/game/states.py b/game/states.py new file mode 100644 index 0000000..6b2ed2e --- /dev/null +++ b/game/states.py @@ -0,0 +1,92 @@ +"""A collection of game states.""" + +from __future__ import annotations + +import attrs +import tcod.console +import tcod.constants +import tcod.event +from tcod.event import KeySym + +import g +import game.menus +import game.world_tools +from game.components import Gold, Graphic, Position +from game.constants import DIRECTION_KEYS +from game.state import Push, Reset, State, StateResult +from game.tags import IsItem, IsPlayer + + +@attrs.define() +class InGame(State): + """Primary in-game state.""" + + def on_event(self, event: tcod.event.Event) -> StateResult: + """Handle events for the in-game state.""" + (player,) = g.world.Q.all_of(tags=[IsPlayer]) + match event: + case tcod.event.Quit(): + raise SystemExit() + case tcod.event.KeyDown(sym=sym) if sym in DIRECTION_KEYS: + player.components[Position] += DIRECTION_KEYS[sym] + # Auto pickup gold + for gold in g.world.Q.all_of(components=[Gold], tags=[player.components[Position], IsItem]): + player.components[Gold] += gold.components[Gold] + text = f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g" + g.world[None].components[("Text", str)] = text + gold.clear() + return None + case tcod.event.KeyDown(sym=KeySym.ESCAPE): + return Push(MainMenu()) + case _: + return None + + def on_draw(self, console: tcod.console.Console) -> None: + """Draw the standard screen.""" + for entity in g.world.Q.all_of(components=[Position, Graphic]): + pos = entity.components[Position] + if not (0 <= pos.x < console.width and 0 <= pos.y < console.height): + continue + graphic = entity.components[Graphic] + console.rgb[["ch", "fg"]][pos.y, pos.x] = graphic.ch, graphic.fg + + if text := g.world[None].components.get(("Text", str)): + console.print(x=0, y=console.height - 1, string=text, fg=(255, 255, 255), bg=(0, 0, 0)) + + +class MainMenu(game.menus.ListMenu): + """Main/escape menu.""" + + __slots__ = () + + def __init__(self) -> None: + """Initialize the main menu.""" + items = [ + game.menus.SelectItem("New game", self.new_game), + game.menus.SelectItem("Quit", self.quit), + ] + if hasattr(g, "world"): + items.insert(0, game.menus.SelectItem("Continue", self.continue_)) + + super().__init__( + items=tuple(items), + selected=0, + x=5, + y=5, + ) + + @staticmethod + def continue_() -> StateResult: + """Return to the game.""" + return Reset(InGame()) + + @staticmethod + def new_game() -> StateResult: + """Begin a new game.""" + g.world = game.world_tools.new_world() + return Reset(InGame()) + + @staticmethod + def quit() -> StateResult: + """Close the program.""" + raise SystemExit() diff --git a/game/tags.py b/game/tags.py new file mode 100644 index 0000000..00f8a0d --- /dev/null +++ b/game/tags.py @@ -0,0 +1,14 @@ +"""Collection of common tags.""" + +from __future__ import annotations + +from typing import Final + +IsPlayer: Final = "IsPlayer" +"""Entity is the player.""" + +IsActor: Final = "IsActor" +"""Entity is an actor.""" + +IsItem: Final = "IsItem" +"""Entity is an item.""" diff --git a/game/world_tools.py b/game/world_tools.py new file mode 100644 index 0000000..6e09e4b --- /dev/null +++ b/game/world_tools.py @@ -0,0 +1,32 @@ +"""Functions for working with worlds.""" + +from __future__ import annotations + +from random import Random + +from tcod.ecs import Registry + +from game.components import Gold, Graphic, Position +from game.tags import IsActor, IsItem, IsPlayer + + +def new_world() -> Registry: + """Return a freshly generated world.""" + world = Registry() + + rng = world[None].components[Random] = Random() + + player = world[object()] + player.components[Position] = Position(5, 5) + player.components[Graphic] = Graphic(ord("@")) + player.components[Gold] = 0 + player.tags |= {IsPlayer, IsActor} + + for _ in range(10): + gold = world[object()] + gold.components[Position] = Position(rng.randint(0, 20), rng.randint(0, 20)) + gold.components[Graphic] = Graphic(ord("$"), fg=(255, 255, 0)) + gold.components[Gold] = rng.randint(1, 10) + gold.tags |= {IsItem} + + return world diff --git a/main.py b/main.py new file mode 100755 index 0000000..51cc264 --- /dev/null +++ b/main.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Main entry-point module. This script is used to start the program.""" + +from __future__ import annotations + +import tcod.console +import tcod.context +import tcod.tileset + +import g +import game.state_tools +import game.states + + +def main() -> None: + """Entry point function.""" + tileset = tcod.tileset.load_tilesheet( + "data/Alloy_curses_12x12.png", columns=16, rows=16, charmap=tcod.tileset.CHARMAP_CP437 + ) + tcod.tileset.procedural_block_elements(tileset=tileset) + g.states = [game.states.MainMenu()] + g.console = tcod.console.Console(80, 50) + with tcod.context.new(console=g.console, tileset=tileset) as g.context: + game.state_tools.main_loop() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..784034c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,63 @@ +# Project configuiration options for Python tools. +[tool.mypy] # https://mypy.readthedocs.io/en/stable/config_file.html +python_version = "3.11" +files = ["."] +warn_unused_configs = true +disallow_any_generics = true +disallow_subclassing_any = true +disallow_untyped_calls = false # Some calls from NumPy are untyped. +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_return_any = true +no_implicit_reexport = true +strict_equality = true + +[tool.ruff] # https://docs.astral.sh/ruff/rules/ +line-length = 120 +target-version = "py311" + +[tool.ruff.lint] +select = [ + "C90", # mccabe + "D", # pydocstyle + "E", # pycodestyle + "W", # pycodestyle + "F", # Pyflakes + "I", # isort + "UP", # pyupgrade + "YTT", # flake8-2020 + "ANN", # flake8-annotations + "S", # flake8-bandit + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "EM", # flake8-errmsg + "EXE", # flake8-executable + "FA", # flake8-future-annotations + "RET", # flake8-return + "ICN", # flake8-import-conventions + "PIE", # flake8-pie + "PT", # flake8-pytest-style + "SIM", # flake8-simplify + "PTH", # flake8-use-pathlib + "PL", # Pylint + "TRY", # tryceratops + "RUF", # NumPy-specific rules + "G", # flake8-logging-format +] +ignore = [ + "E501", # line-too-long + "S101", # assert + "ANN101", # missing-type-self + "ANN102", # missing-type-cls + "S311", # suspicious-non-cryptographic-random-usage + "PLR0913", # too-many-arguments +] + +[tool.ruff.lint.pydocstyle] # https://docs.astral.sh/ruff/settings/#lintpydocstyle +convention = "google" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..610b558 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +attrs ~= 22.2 +tcod ~= 16.0 +tcod-ecs ~= 5.1