first commit

This commit is contained in:
Lukas Nöllemeyer 2024-08-16 17:20:07 +02:00
commit 85be45e2b8
18 changed files with 605 additions and 0 deletions

15
.vscode/launch.json vendored Normal file
View file

@ -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
}
]
}

24
.vscode/settings.json vendored Normal file
View file

@ -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"
]
}

24
LICENSE Normal file
View file

@ -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 <https://unlicense.org>

1
README.md Normal file
View file

@ -0,0 +1 @@
# python-tcod-tutorial-2023

BIN
data/Alloy_curses_12x12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

21
g.py Normal file
View file

@ -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."""

1
game/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Game namespace package."""

45
game/components.py Normal file
View file

@ -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."""

36
game/constants.py Normal file
View file

@ -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),
}

101
game/menus.py Normal file
View file

@ -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)

44
game/state.py Normal file
View file

@ -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."""

61
game/state_tools.py Normal file
View file

@ -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

92
game/states.py Normal file
View file

@ -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()

14
game/tags.py Normal file
View file

@ -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."""

32
game/world_tools.py Normal file
View file

@ -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

28
main.py Executable file
View file

@ -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()

63
pyproject.toml Normal file
View file

@ -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"

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
attrs ~= 22.2
tcod ~= 16.0
tcod-ecs ~= 5.1