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 +68 -0
- termquest/animation.py +69 -0
- termquest/backend.py +173 -0
- termquest/buffer.py +105 -0
- termquest/camera.py +50 -0
- termquest/cli.py +85 -0
- termquest/color.py +32 -0
- termquest/config.py +30 -0
- termquest/crash.py +21 -0
- termquest/demo.py +60 -0
- termquest/doctor.py +23 -0
- termquest/ecs.py +97 -0
- termquest/engine.py +64 -0
- termquest/entity.py +103 -0
- termquest/errors.py +23 -0
- termquest/geometry.py +41 -0
- termquest/hud.py +9 -0
- termquest/image.py +39 -0
- termquest/input.py +98 -0
- termquest/lan.py +155 -0
- termquest/logging.py +24 -0
- termquest/network.py +192 -0
- termquest/p2p.py +52 -0
- termquest/physics.py +51 -0
- termquest/platform_network.py +111 -0
- termquest/plugins.py +59 -0
- termquest/profiler.py +35 -0
- termquest/resources.py +43 -0
- termquest/save.py +21 -0
- termquest/scaffolding.py +18 -0
- termquest/scene.py +88 -0
- termquest/sprite.py +42 -0
- termquest/story.py +215 -0
- termquest/story_runner.py +37 -0
- termquest/tilemap.py +46 -0
- termquest/timer.py +24 -0
- termquest/ui.py +439 -0
- termquest/validation.py +32 -0
- termquest/world.py +64 -0
- termquest-1.0.0.dist-info/METADATA +332 -0
- termquest-1.0.0.dist-info/RECORD +45 -0
- termquest-1.0.0.dist-info/WHEEL +5 -0
- termquest-1.0.0.dist-info/entry_points.txt +4 -0
- termquest-1.0.0.dist-info/licenses/LICENSE +9 -0
- termquest-1.0.0.dist-info/top_level.txt +1 -0
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
|