termquest 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
termquest/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ from .entity import Entity
2
+ from .ecs import Component, EventBus, Health, Inventory, Script, System, Transform
3
+ from .physics import PhysicsBody
4
+ from .timer import Timer
5
+ from .hud import bar
6
+ from .save import SaveManager
7
+ from .animation import Animation, Animator
8
+ from .backend import ConsoleBackend, WindowBackend
9
+ from .buffer import Cell, ScreenBuffer
10
+ from .camera import Camera
11
+ from .color import Color
12
+ from .engine import Game
13
+ from .geometry import Rect, Vec2
14
+ from .image import ImageTexture
15
+ from .input import InputState
16
+ from .scene import Scene, SceneManager
17
+ from .sprite import Sprite
18
+ from .story import Story, StoryNode, StoryChoice, StoryScene, StoryFormatError
19
+ from .tilemap import Tile, TileMap
20
+ from .ui import (Button, CheckBox, ChoiceMenu, DialogueBox, HBox, Label, Panel, ProgressBar, Slider, TextBox, Theme, Typewriter, UIElement, UIManager, VBox, Window)
21
+ from .world import Layer, World
22
+
23
+ __all__ = [
24
+ 'Animation','Animator','Camera','Cell','ChoiceMenu','Color','ConsoleBackend',
25
+ 'DialogueBox','Game','ImageTexture','InputState','Layer','Rect','Scene',
26
+ 'SceneManager','ScreenBuffer','Sprite','Story','StoryChoice','StoryNode',
27
+ 'StoryScene','StoryFormatError','Tile','TileMap','Typewriter','Vec2',
28
+ 'WindowBackend','World',
29
+ "Entity", "Component", "EventBus", "Health", "Inventory", "Script", "System", "Transform",
30
+ "PhysicsBody",
31
+ "Timer",
32
+ "bar",
33
+ "SaveManager",
34
+ "Button", "CheckBox", "HBox", "Label", "Panel", "ProgressBar",
35
+ "Slider", "TextBox", "Theme", "UIElement", "UIManager", "VBox", "Window",
36
+ ]
37
+
38
+ from .config import ProjectConfig
39
+ from .resources import ResourceManager
40
+ from .plugins import Plugin, PluginManager
41
+ from .scaffolding import create_project
42
+ from .doctor import Check, run_doctor
43
+
44
+ __version__ = "1.0.0"
45
+ __all__ += ["ProjectConfig", "ResourceManager", "Plugin", "PluginManager", "create_project", "Check", "run_doctor"]
46
+
47
+ from .errors import (TermQuestError, ProjectConfigError, ResourceNotFoundError, SceneLoadError, PluginLoadError, InvalidComponentError)
48
+ from .logging import configure_logging, logger
49
+ from .profiler import Profiler
50
+ from .validation import ValidationIssue, validate_project
51
+ from .crash import write_crash_report
52
+ __all__ += ["TermQuestError", "ProjectConfigError", "ResourceNotFoundError", "SceneLoadError", "PluginLoadError", "InvalidComponentError", "configure_logging", "logger", "Profiler", "ValidationIssue", "validate_project", "write_crash_report"]
53
+
54
+ from .errors import NetworkError, PlatformSDKUnavailableError
55
+ from .network import (Lobby, LobbyProvider, MultiplayerSession, NetworkEvent,
56
+ NetworkEventType, Packet, Peer, Reliability, Transport)
57
+ from .p2p import InMemoryP2PTransport
58
+ from .lan import LanDiscovery, LanServer, UdpP2PTransport
59
+ from .platform_network import (BridgeLobbyProvider, BridgeTransport,
60
+ EOSLobbyProvider, EOSTransport,
61
+ SteamLobbyProvider, SteamTransport)
62
+ __all__ += [
63
+ "NetworkError", "PlatformSDKUnavailableError", "Lobby", "LobbyProvider",
64
+ "MultiplayerSession", "NetworkEvent", "NetworkEventType", "Packet", "Peer",
65
+ "Reliability", "Transport", "InMemoryP2PTransport", "LanDiscovery",
66
+ "LanServer", "UdpP2PTransport", "BridgeTransport", "BridgeLobbyProvider",
67
+ "SteamTransport", "SteamLobbyProvider", "EOSTransport", "EOSLobbyProvider",
68
+ ]
termquest/animation.py ADDED
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from .sprite import Sprite
4
+
5
+ @dataclass(slots=True)
6
+ class Animation:
7
+ frames: list[Sprite]
8
+ fps: float = 8.0
9
+ loop: bool = True
10
+ playing: bool = True
11
+ _time: float = 0.0
12
+ _index: int = 0
13
+
14
+ def __post_init__(self):
15
+ if not self.frames: raise ValueError('Animation needs at least one frame.')
16
+ if self.fps <= 0: raise ValueError('fps must be positive.')
17
+
18
+ @property
19
+ def frame(self) -> Sprite:
20
+ return self.frames[self._index]
21
+
22
+ @property
23
+ def finished(self) -> bool:
24
+ return not self.loop and self._index == len(self.frames)-1 and not self.playing
25
+
26
+ def play(self, restart: bool = False) -> None:
27
+ if restart: self.reset()
28
+ self.playing = True
29
+
30
+ def pause(self) -> None: self.playing = False
31
+
32
+ def reset(self) -> None:
33
+ self._time=0.0; self._index=0
34
+
35
+ def update(self, dt: float) -> None:
36
+ if not self.playing or len(self.frames)==1: return
37
+ self._time += dt
38
+ step=1.0/self.fps
39
+ while self._time >= step:
40
+ self._time -= step
41
+ if self._index + 1 >= len(self.frames):
42
+ if self.loop: self._index=0
43
+ else:
44
+ self._index=len(self.frames)-1; self.playing=False; break
45
+ else: self._index += 1
46
+
47
+ @dataclass(slots=True)
48
+ class Animator:
49
+ animations: dict[str, Animation] = field(default_factory=dict)
50
+ current_name: str | None = None
51
+
52
+ def add(self, name: str, animation: Animation) -> "Animator":
53
+ self.animations[name]=animation
54
+ if self.current_name is None: self.current_name=name
55
+ return self
56
+
57
+ def play(self, name: str, restart: bool = False) -> None:
58
+ if name not in self.animations: raise KeyError(name)
59
+ if name != self.current_name or restart:
60
+ self.current_name=name; self.animations[name].reset()
61
+ self.animations[name].play()
62
+
63
+ @property
64
+ def frame(self) -> Sprite:
65
+ if self.current_name is None: raise RuntimeError('Animator has no animation.')
66
+ return self.animations[self.current_name].frame
67
+
68
+ def update(self, dt: float) -> None:
69
+ if self.current_name is not None: self.animations[self.current_name].update(dt)
termquest/backend.py ADDED
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+ import os
3
+ import sys
4
+ from abc import ABC, abstractmethod
5
+ from .buffer import ScreenBuffer
6
+ from .input import InputState, ConsoleInputReader
7
+
8
+ class Backend(ABC):
9
+ def __init__(self, width: int, height: int) -> None:
10
+ self.width = width
11
+ self.height = height
12
+
13
+ @abstractmethod
14
+ def open(self, input_state: InputState) -> None: ...
15
+
16
+ @abstractmethod
17
+ def poll(self, input_state: InputState) -> bool: ...
18
+
19
+ @abstractmethod
20
+ def present(self, screen: ScreenBuffer) -> None: ...
21
+
22
+ @abstractmethod
23
+ def close(self) -> None: ...
24
+
25
+ class ConsoleBackend(Backend):
26
+ def __init__(self, width: int, height: int, title: str = "TermQuest") -> None:
27
+ super().__init__(width, height)
28
+ self.title = title
29
+ self._reader = None
30
+
31
+ def open(self, input_state: InputState) -> None:
32
+ if os.name == "nt":
33
+ os.system("")
34
+ os.system(f"title {self.title}")
35
+ self._reader = ConsoleInputReader(input_state)
36
+ self._reader.__enter__()
37
+ sys.stdout.write("\x1b[?25l\x1b[2J\x1b[H")
38
+ sys.stdout.flush()
39
+
40
+ def poll(self, input_state: InputState) -> bool:
41
+ assert self._reader is not None
42
+ self._reader.poll()
43
+ return True
44
+
45
+ def present(self, screen: ScreenBuffer) -> None:
46
+ out = ["\x1b[H"]
47
+ last_fg = last_bg = None
48
+ for y in range(screen.height):
49
+ for x in range(screen.width):
50
+ cell = screen.get(x, y)
51
+ if cell.fg != last_fg:
52
+ out.append(f"\x1b[38;2;{cell.fg.r};{cell.fg.g};{cell.fg.b}m")
53
+ last_fg = cell.fg
54
+ if cell.bg != last_bg:
55
+ out.append(f"\x1b[48;2;{cell.bg.r};{cell.bg.g};{cell.bg.b}m")
56
+ last_bg = cell.bg
57
+ out.append(cell.char)
58
+ if y != screen.height - 1:
59
+ out.append("\n")
60
+ out.append("\x1b[0m")
61
+ sys.stdout.write("".join(out))
62
+ sys.stdout.flush()
63
+
64
+ def close(self) -> None:
65
+ if self._reader is not None:
66
+ self._reader.__exit__(None, None, None)
67
+ sys.stdout.write("\x1b[0m\x1b[?25h\n")
68
+ sys.stdout.flush()
69
+
70
+ class WindowBackend(Backend):
71
+ def __init__(
72
+ self,
73
+ width: int,
74
+ height: int,
75
+ title: str = "TermQuest",
76
+ font_family: str = "Consolas",
77
+ font_size: int = 18,
78
+ ) -> None:
79
+ super().__init__(width, height)
80
+ self.title = title
81
+ self.font_family = font_family
82
+ self.font_size = font_size
83
+ self.root = None
84
+ self.text = None
85
+ self._alive = True
86
+
87
+ def open(self, input_state: InputState) -> None:
88
+ import tkinter as tk
89
+ from tkinter import font
90
+
91
+ self.root = tk.Tk()
92
+ self.root.title(self.title)
93
+ self.root.resizable(False, False)
94
+
95
+ fixed = font.Font(family=self.font_family, size=self.font_size)
96
+ self.text = tk.Text(
97
+ self.root,
98
+ width=self.width,
99
+ height=self.height,
100
+ font=fixed,
101
+ wrap="none",
102
+ borderwidth=0,
103
+ highlightthickness=0,
104
+ state="disabled",
105
+ cursor="arrow",
106
+ )
107
+ self.text.pack()
108
+ self.text.tag_configure("_default", foreground="#ffffff", background="#000000")
109
+
110
+ def down(event):
111
+ key = event.keysym.lower()
112
+ aliases = {"return": "enter", "esc": "escape"}
113
+ input_state.feed_key_down(aliases.get(key, key))
114
+ if event.char and event.char.isprintable():
115
+ input_state.feed_text(event.char)
116
+ return "break"
117
+
118
+ def up(event):
119
+ key = event.keysym.lower()
120
+ aliases = {"return": "enter", "esc": "escape"}
121
+ input_state.feed_key_up(aliases.get(key, key))
122
+ return "break"
123
+
124
+ def close():
125
+ self._alive = False
126
+
127
+ self.root.bind("<KeyPress>", down)
128
+ self.root.bind("<KeyRelease>", up)
129
+ self.root.protocol("WM_DELETE_WINDOW", close)
130
+ self.root.focus_force()
131
+
132
+ def poll(self, input_state: InputState) -> bool:
133
+ if not self._alive or self.root is None:
134
+ return False
135
+ try:
136
+ self.root.update_idletasks()
137
+ self.root.update()
138
+ return True
139
+ except Exception:
140
+ return False
141
+
142
+ def present(self, screen: ScreenBuffer) -> None:
143
+ if self.text is None:
144
+ return
145
+
146
+ self.text.configure(state="normal")
147
+ self.text.delete("1.0", "end")
148
+
149
+ tag_cache = {}
150
+ for y in range(screen.height):
151
+ for x in range(screen.width):
152
+ cell = screen.get(x, y)
153
+ key = (cell.fg, cell.bg)
154
+ tag = tag_cache.get(key)
155
+ if tag is None:
156
+ tag = f"c{len(tag_cache)}"
157
+ tag_cache[key] = tag
158
+ self.text.tag_configure(
159
+ tag,
160
+ foreground=f"#{cell.fg.r:02x}{cell.fg.g:02x}{cell.fg.b:02x}",
161
+ background=f"#{cell.bg.r:02x}{cell.bg.g:02x}{cell.bg.b:02x}",
162
+ )
163
+ self.text.insert("end", cell.char, tag)
164
+ if y != screen.height - 1:
165
+ self.text.insert("end", "\n")
166
+ self.text.configure(state="disabled")
167
+
168
+ def close(self) -> None:
169
+ if self.root is not None:
170
+ try:
171
+ self.root.destroy()
172
+ except Exception:
173
+ pass
termquest/buffer.py ADDED
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from .color import Color, BLACK, WHITE
4
+
5
+ @dataclass(slots=True)
6
+ class Cell:
7
+ char: str = " "
8
+ fg: Color = WHITE
9
+ bg: Color = BLACK
10
+
11
+ def __post_init__(self) -> None:
12
+ if len(self.char) != 1:
13
+ raise ValueError("A cell must contain exactly one character.")
14
+
15
+ class ScreenBuffer:
16
+ def __init__(self, width: int, height: int) -> None:
17
+ if width <= 0 or height <= 0:
18
+ raise ValueError("Screen dimensions must be positive.")
19
+ self.width = width
20
+ self.height = height
21
+ self._cells = [Cell() for _ in range(width * height)]
22
+
23
+ def _index(self, x: int, y: int) -> int:
24
+ return y * self.width + x
25
+
26
+ def in_bounds(self, x: int, y: int) -> bool:
27
+ return 0 <= x < self.width and 0 <= y < self.height
28
+
29
+ def get(self, x: int, y: int) -> Cell:
30
+ if not self.in_bounds(x, y):
31
+ raise IndexError((x, y))
32
+ return self._cells[self._index(x, y)]
33
+
34
+ def put(
35
+ self,
36
+ x: int,
37
+ y: int,
38
+ char: str = " ",
39
+ fg: Color = WHITE,
40
+ bg: Color = BLACK,
41
+ ) -> None:
42
+ if self.in_bounds(x, y):
43
+ self._cells[self._index(x, y)] = Cell(char, fg, bg)
44
+
45
+ def clear(self, char: str = " ", fg: Color = WHITE, bg: Color = BLACK) -> None:
46
+ cell = Cell(char, fg, bg)
47
+ self._cells = [Cell(cell.char, cell.fg, cell.bg) for _ in self._cells]
48
+
49
+ def text(
50
+ self,
51
+ x: int,
52
+ y: int,
53
+ value: str,
54
+ fg: Color = WHITE,
55
+ bg: Color = BLACK,
56
+ wrap: bool = False,
57
+ ) -> None:
58
+ cx, cy = x, y
59
+ for ch in value:
60
+ if ch == "\n":
61
+ cx, cy = x, cy + 1
62
+ continue
63
+ if wrap and cx >= self.width:
64
+ cx, cy = 0, cy + 1
65
+ self.put(cx, cy, ch, fg, bg)
66
+ cx += 1
67
+
68
+ def fill_rect(
69
+ self,
70
+ x: int,
71
+ y: int,
72
+ width: int,
73
+ height: int,
74
+ char: str = " ",
75
+ fg: Color = WHITE,
76
+ bg: Color = BLACK,
77
+ ) -> None:
78
+ for py in range(y, y + height):
79
+ for px in range(x, x + width):
80
+ self.put(px, py, char, fg, bg)
81
+
82
+ def box(
83
+ self,
84
+ x: int,
85
+ y: int,
86
+ width: int,
87
+ height: int,
88
+ fg: Color = WHITE,
89
+ bg: Color = BLACK,
90
+ ) -> None:
91
+ if width < 2 or height < 2:
92
+ return
93
+ self.put(x, y, "┌", fg, bg)
94
+ self.put(x + width - 1, y, "┐", fg, bg)
95
+ self.put(x, y + height - 1, "└", fg, bg)
96
+ self.put(x + width - 1, y + height - 1, "┘", fg, bg)
97
+ for px in range(x + 1, x + width - 1):
98
+ self.put(px, y, "─", fg, bg)
99
+ self.put(px, y + height - 1, "─", fg, bg)
100
+ for py in range(y + 1, y + height - 1):
101
+ self.put(x, py, "│", fg, bg)
102
+ self.put(x + width - 1, py, "│", fg, bg)
103
+
104
+ def cells(self) -> list[Cell]:
105
+ return self._cells
termquest/camera.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+ import random
3
+ from dataclasses import dataclass
4
+ from .geometry import Vec2
5
+
6
+ @dataclass(slots=True)
7
+ class Camera:
8
+ x: float = 0.0
9
+ y: float = 0.0
10
+ smoothing: float = 0.0
11
+ _target: object | None = None
12
+ _shake_time: float = 0.0
13
+ _shake_strength: float = 0.0
14
+ _offset: Vec2 = None
15
+
16
+ def __post_init__(self):
17
+ self._offset = Vec2()
18
+
19
+ def follow(self, target: object) -> None:
20
+ self._target = target
21
+
22
+ def unfollow(self) -> None:
23
+ self._target = None
24
+
25
+ def move(self, dx: float, dy: float) -> None:
26
+ self.x += dx
27
+ self.y += dy
28
+
29
+ def shake(self, strength: float = 1.0, duration: float = 0.2) -> None:
30
+ self._shake_strength = max(0.0, strength)
31
+ self._shake_time = max(0.0, duration)
32
+
33
+ def update(self, dt: float, viewport_width: int, viewport_height: int) -> None:
34
+ if self._target is not None:
35
+ tx = float(getattr(self._target, 'x', 0)) + float(getattr(self._target, 'width', 1)) / 2 - viewport_width / 2
36
+ ty = float(getattr(self._target, 'y', 0)) + float(getattr(self._target, 'height', 1)) / 2 - viewport_height / 2
37
+ if self.smoothing <= 0:
38
+ self.x, self.y = tx, ty
39
+ else:
40
+ alpha = min(1.0, self.smoothing * dt)
41
+ self.x += (tx - self.x) * alpha
42
+ self.y += (ty - self.y) * alpha
43
+ if self._shake_time > 0:
44
+ self._shake_time = max(0.0, self._shake_time - dt)
45
+ self._offset = Vec2(random.uniform(-1,1)*self._shake_strength, random.uniform(-1,1)*self._shake_strength)
46
+ else:
47
+ self._offset = Vec2()
48
+
49
+ def world_to_screen(self, x: float, y: float) -> tuple[int, int]:
50
+ return round(x - self.x + self._offset.x), round(y - self.y + self._offset.y)
termquest/cli.py ADDED
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+ import argparse, os, platform, runpy, shutil, subprocess, sys, time
3
+ from pathlib import Path
4
+ from .doctor import run_doctor
5
+ from .plugins import PluginManager
6
+ from .scaffolding import create_project
7
+ from .validation import validate_project
8
+ VERSION="1.0.0"
9
+
10
+ def parser() -> argparse.ArgumentParser:
11
+ p=argparse.ArgumentParser(prog="termquest",description="TermQuest developer tools")
12
+ p.add_argument("--version",action="version",version=f"TermQuest {VERSION}")
13
+ commands=p.add_subparsers(dest="command")
14
+ new=commands.add_parser("new"); new.add_argument("name"); new.add_argument("--destination",default="."); new.add_argument("--force",action="store_true")
15
+ run=commands.add_parser("run"); run.add_argument("path",nargs="?",default=".")
16
+ doctor=commands.add_parser("doctor"); doctor.add_argument("path",nargs="?",default=".")
17
+ test=commands.add_parser("test"); test.add_argument("path",nargs="?",default=".")
18
+ clean=commands.add_parser("clean"); clean.add_argument("path",nargs="?",default=".")
19
+ validate=commands.add_parser("validate"); validate.add_argument("path",nargs="?",default=".")
20
+ plugins=commands.add_parser("list-plugins"); plugins.add_argument("path",nargs="?",default=".")
21
+ bench=commands.add_parser("benchmark"); bench.add_argument("--iterations",type=int,default=100000)
22
+ network=commands.add_parser("network-info")
23
+ network.add_argument("--backend", choices=("lan","p2p","steam","eos","all"), default="all")
24
+ commands.add_parser("info"); commands.add_parser("version")
25
+ return p
26
+
27
+ def main(argv: list[str] | None = None) -> int:
28
+ args=parser().parse_args(argv)
29
+ if args.command=="new":
30
+ try: target=create_project(args.name,args.destination,force=args.force)
31
+ except FileExistsError as exc: print(f"error: {exc}",file=sys.stderr); return 2
32
+ print(f"Created TermQuest project: {target}"); return 0
33
+ if args.command=="run":
34
+ root=Path(args.path).resolve(); main_file=root/"main.py"
35
+ if not main_file.exists(): print(f"error: {main_file} not found",file=sys.stderr); return 2
36
+ old=Path.cwd(); sys.path.insert(0,str(root))
37
+ try: os.chdir(root); runpy.run_path(str(main_file),run_name="__main__")
38
+ finally: os.chdir(old); sys.path.pop(0)
39
+ return 0
40
+ if args.command=="doctor":
41
+ checks=run_doctor(args.path)
42
+ for c in checks: print(f"[{'OK' if c.ok else 'FAIL'}] {c.name}: {c.detail}")
43
+ return 0 if all(c.ok or c.name in {"Pillow","UTF-8"} for c in checks) else 1
44
+ if args.command=="test": return subprocess.call([sys.executable,"-m","pytest",str(Path(args.path)/"tests")])
45
+ if args.command=="clean":
46
+ removed=0
47
+ for name in ("__pycache__",".pytest_cache"):
48
+ for path in Path(args.path).rglob(name):
49
+ if path.is_dir(): shutil.rmtree(path); removed+=1
50
+ print(f"Removed {removed} cache directories."); return 0
51
+ if args.command=="validate":
52
+ issues=validate_project(args.path)
53
+ if not issues: print("Project validation passed."); return 0
54
+ for issue in issues: print(f"[{issue.level.upper()}] {issue.path}: {issue.message}")
55
+ return 1 if any(i.level=="error" for i in issues) else 0
56
+ if args.command=="list-plugins":
57
+ manager=PluginManager(Path(args.path)/"plugins")
58
+ paths=manager.discover()
59
+ if not paths: print("No plugins found.")
60
+ else:
61
+ for path in paths: print(path.stem)
62
+ return 0
63
+ if args.command=="benchmark":
64
+ start=time.perf_counter(); total=0
65
+ for i in range(args.iterations): total += i
66
+ elapsed=time.perf_counter()-start
67
+ print(f"{args.iterations} iterations in {elapsed*1000:.3f} ms ({total})"); return 0
68
+ if args.command=="info":
69
+ print(f"TermQuest {VERSION}")
70
+ print(f"Python {platform.python_version()}")
71
+ print(platform.platform()); return 0
72
+ if args.command=="network-info":
73
+ backends = {
74
+ "lan": "Built-in UDP discovery + direct UDP transport",
75
+ "p2p": "Built-in in-memory/direct peer transport APIs",
76
+ "steam": "Bridge adapter for Steamworks Networking Messages/Sockets and Lobbies",
77
+ "eos": "Bridge adapter for EOS P2P and Lobbies",
78
+ }
79
+ for name, detail in backends.items():
80
+ if args.backend in ("all", name): print(f"{name}: {detail}")
81
+ return 0
82
+ if args.command=="version": print(f"TermQuest {VERSION}"); return 0
83
+ parser().print_help(); return 0
84
+
85
+ if __name__=="__main__": raise SystemExit(main())
termquest/color.py ADDED
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+
4
+ @dataclass(frozen=True, slots=True)
5
+ class Color:
6
+ r: int
7
+ g: int
8
+ b: int
9
+
10
+ def __post_init__(self) -> None:
11
+ for value in (self.r, self.g, self.b):
12
+ if not 0 <= value <= 255:
13
+ raise ValueError("RGB values must be between 0 and 255.")
14
+
15
+ @classmethod
16
+ def from_hex(cls, value: str) -> "Color":
17
+ value = value.strip().lstrip("#")
18
+ if len(value) != 6:
19
+ raise ValueError("Expected a 6-digit hex color.")
20
+ return cls(*(int(value[i:i+2], 16) for i in (0, 2, 4)))
21
+
22
+ @staticmethod
23
+ def lerp(a: "Color", b: "Color", t: float) -> "Color":
24
+ t = max(0.0, min(1.0, t))
25
+ return Color(
26
+ round(a.r + (b.r - a.r) * t),
27
+ round(a.g + (b.g - a.g) * t),
28
+ round(a.b + (b.b - a.b) * t),
29
+ )
30
+
31
+ BLACK = Color(0, 0, 0)
32
+ WHITE = Color(255, 255, 255)
termquest/config.py ADDED
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ import tomllib
5
+
6
+ @dataclass(slots=True)
7
+ class ProjectConfig:
8
+ title: str = "TermQuest Game"
9
+ width: int = 80
10
+ height: int = 30
11
+ fps: int = 30
12
+ backend: str = "console"
13
+ start_scene: str = "main"
14
+
15
+ @classmethod
16
+ def load(cls, path: str | Path = "termquest.toml") -> "ProjectConfig":
17
+ file = Path(path)
18
+ if not file.exists():
19
+ return cls()
20
+ with file.open("rb") as stream:
21
+ values = tomllib.load(stream).get("game", {})
22
+ defaults = cls()
23
+ return cls(
24
+ title=str(values.get("title", defaults.title)),
25
+ width=int(values.get("width", defaults.width)),
26
+ height=int(values.get("height", defaults.height)),
27
+ fps=int(values.get("fps", defaults.fps)),
28
+ backend=str(values.get("backend", defaults.backend)),
29
+ start_scene=str(values.get("start_scene", defaults.start_scene)),
30
+ )
termquest/crash.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+ import platform, sys, traceback
5
+
6
+ def write_crash_report(exc: BaseException, directory: str | Path = "crash_reports", **context: object) -> Path:
7
+ directory = Path(directory)
8
+ directory.mkdir(parents=True, exist_ok=True)
9
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
10
+ path = directory / f"crash-{stamp}.txt"
11
+ lines = [
12
+ "TermQuest crash report",
13
+ f"Python: {sys.version}",
14
+ f"Platform: {platform.platform()}",
15
+ f"Exception: {type(exc).__name__}: {exc}",
16
+ ]
17
+ for key, value in context.items():
18
+ lines.append(f"{key}: {value}")
19
+ lines.extend(["", "Traceback:", "".join(traceback.format_exception(exc))])
20
+ path.write_text("\n".join(lines), encoding="utf-8")
21
+ return path