saga2d 0.2.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.
- saga2d/__init__.py +43 -0
- saga2d/_fileio.py +52 -0
- saga2d/_scene_stack.py +192 -0
- saga2d/actions.py +496 -0
- saga2d/animation.py +202 -0
- saga2d/assets/fonts/Nunito-ExtraBold.ttf +0 -0
- saga2d/assets/fonts/Nunito-SemiBold.ttf +0 -0
- saga2d/assets/fonts/Nunito.ttf +0 -0
- saga2d/assets/fonts/OFL.txt +93 -0
- saga2d/assets.py +114 -0
- saga2d/audio.py +177 -0
- saga2d/backends/__init__.py +3 -0
- saga2d/backends/base.py +263 -0
- saga2d/backends/mock_backend.py +298 -0
- saga2d/backends/pyglet_backend.py +797 -0
- saga2d/effects.py +398 -0
- saga2d/fonts.py +29 -0
- saga2d/game.py +512 -0
- saga2d/hexgrid.py +138 -0
- saga2d/input.py +131 -0
- saga2d/multiplayer_ui.py +362 -0
- saga2d/network.py +264 -0
- saga2d/online.py +253 -0
- saga2d/packaging/__init__.py +205 -0
- saga2d/packaging/entry.py +40 -0
- saga2d/packaging/game.iss +39 -0
- saga2d/packaging/game.spec +22 -0
- saga2d/packaging/install.py +82 -0
- saga2d/packaging/verify.py +212 -0
- saga2d/release.py +95 -0
- saga2d/rendering/__init__.py +6 -0
- saga2d/rendering/_text.py +58 -0
- saga2d/rendering/camera.py +351 -0
- saga2d/rendering/layers.py +60 -0
- saga2d/rendering/particles.py +177 -0
- saga2d/rendering/shapes.py +81 -0
- saga2d/rendering/sprite.py +390 -0
- saga2d/save.py +231 -0
- saga2d/scene.py +387 -0
- saga2d/server/__init__.py +412 -0
- saga2d/server/__main__.py +52 -0
- saga2d/server/games.py +73 -0
- saga2d/server/storage.py +89 -0
- saga2d/settings.py +184 -0
- saga2d/testing/__init__.py +190 -0
- saga2d/testing/cpu_budget.py +33 -0
- saga2d/testing/fixtures.py +18 -0
- saga2d/testing/native_frames.py +15 -0
- saga2d/testing/online.py +132 -0
- saga2d/ui/__init__.py +12 -0
- saga2d/ui/base.py +346 -0
- saga2d/ui/components.py +570 -0
- saga2d/ui/layout.py +75 -0
- saga2d/ui/minimap.py +97 -0
- saga2d/ui/theme.py +182 -0
- saga2d/util/__init__.py +1 -0
- saga2d/util/collision.py +51 -0
- saga2d/util/reactive.py +112 -0
- saga2d/util/timer.py +60 -0
- saga2d/util/tween.py +217 -0
- saga2d-0.2.0.dist-info/METADATA +138 -0
- saga2d-0.2.0.dist-info/RECORD +64 -0
- saga2d-0.2.0.dist-info/WHEEL +4 -0
- saga2d-0.2.0.dist-info/licenses/LICENSE +21 -0
saga2d/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Saga2D — a small Python framework for 2D games.
|
|
2
|
+
|
|
3
|
+
Game code imports from here::
|
|
4
|
+
|
|
5
|
+
from saga2d import Game, Scene, Sprite, Camera, Label, Button, Anchor
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
|
9
|
+
|
|
10
|
+
import pyglet
|
|
11
|
+
|
|
12
|
+
# pyglet wraps every GL call in an error check by default; a busy frame makes
|
|
13
|
+
# thousands of them. The option is read when pyglet.gl loads, so it is set
|
|
14
|
+
# here, before any backend or font module imports it.
|
|
15
|
+
pyglet.options["debug_gl"] = False
|
|
16
|
+
|
|
17
|
+
from saga2d.actions import Action, Delay, Do, FadeIn, FadeOut, MoveTo, Parallel, PlayAnim, Remove, Repeat, Sequence
|
|
18
|
+
from saga2d.animation import AnimationDef
|
|
19
|
+
from saga2d.assets import AssetManager, AssetNotFoundError
|
|
20
|
+
from saga2d.audio import AudioManager
|
|
21
|
+
from saga2d.backends.base import Event, KeyEvent, MouseEvent, WindowEvent
|
|
22
|
+
from saga2d.game import Game
|
|
23
|
+
from saga2d.hexgrid import HexGrid
|
|
24
|
+
from saga2d.multiplayer_ui import MatchLobby, MatchMenu, add_match_arguments, match_from_arguments
|
|
25
|
+
from saga2d.network import CommandError, MatchClient, MatchHost
|
|
26
|
+
from saga2d.input import InputEvent, InputManager
|
|
27
|
+
from saga2d.rendering import Camera, ParticleEmitter, RenderLayer, Sprite, SpriteAnchor
|
|
28
|
+
from saga2d.save import SaveError, SaveManager
|
|
29
|
+
from saga2d.scene import Scene
|
|
30
|
+
from saga2d.settings import Settings, SettingsError
|
|
31
|
+
from saga2d.ui import Anchor, Button, Column, Component, Image, KeyHints, Label, Layout, Minimap, Panel, ProgressBar, Row, Style, TextStyle, Theme
|
|
32
|
+
from saga2d.util.collision import Rect, aabb_overlap
|
|
33
|
+
from saga2d.util.reactive import ReactiveValue
|
|
34
|
+
from saga2d.util.tween import Ease, tween
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"CommandError", "MatchClient", "MatchHost", "MatchLobby", "MatchMenu", "add_match_arguments", "match_from_arguments",
|
|
38
|
+
"Action", "Anchor", "AnimationDef", "AssetManager", "AssetNotFoundError", "AudioManager", "Button", "Camera",
|
|
39
|
+
"Column", "Component", "Delay", "Do", "Ease", "Event", "FadeIn", "FadeOut", "Game", "HexGrid", "Image", "InputEvent", "InputManager",
|
|
40
|
+
"KeyEvent", "KeyHints", "Label", "Layout", "Minimap", "MouseEvent", "MoveTo", "Panel", "Parallel", "ParticleEmitter", "PlayAnim",
|
|
41
|
+
"ProgressBar", "ReactiveValue", "Rect", "Remove", "RenderLayer", "Repeat", "Row", "SaveError", "SaveManager",
|
|
42
|
+
"Scene", "Sequence", "Settings", "SettingsError", "Sprite", "SpriteAnchor", "Style", "TextStyle", "Theme", "WindowEvent", "aabb_overlap", "tween",
|
|
43
|
+
]
|
saga2d/_fileio.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Private durable file replacement shared by save slots and preferences.
|
|
2
|
+
|
|
3
|
+
Callers serialize and validate their data before entering this module. They
|
|
4
|
+
also own backup/recovery policy; this module only stages, syncs and replaces.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import tempfile
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def durable_write(path: Path, data: bytes, *, backup: tuple[Path, bytes] | None = None) -> None:
|
|
15
|
+
"""Replace a file after syncing its bytes, optionally retaining prior data.
|
|
16
|
+
|
|
17
|
+
Before the current replacement, a failure leaves the current file intact.
|
|
18
|
+
A directory-sync failure after replacement is reported even though the
|
|
19
|
+
replacement may already be visible. A supplied backup remains recoverable.
|
|
20
|
+
"""
|
|
21
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
with _staged_file(path, data) as staged:
|
|
23
|
+
if backup is not None:
|
|
24
|
+
backup_path, previous = backup
|
|
25
|
+
with _staged_file(backup_path, previous) as staged_backup:
|
|
26
|
+
staged_backup.replace(backup_path)
|
|
27
|
+
_sync_directory(backup_path.parent)
|
|
28
|
+
staged.replace(path)
|
|
29
|
+
_sync_directory(path.parent)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@contextmanager
|
|
33
|
+
def _staged_file(path: Path, data: bytes) -> Iterator[Path]:
|
|
34
|
+
descriptor, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
35
|
+
staged = Path(name)
|
|
36
|
+
try:
|
|
37
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
38
|
+
stream.write(data)
|
|
39
|
+
stream.flush()
|
|
40
|
+
os.fsync(stream.fileno())
|
|
41
|
+
yield staged
|
|
42
|
+
finally:
|
|
43
|
+
staged.unlink(missing_ok=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _sync_directory(directory: Path) -> None:
|
|
47
|
+
if os.name == "posix":
|
|
48
|
+
descriptor = os.open(directory, os.O_RDONLY)
|
|
49
|
+
try:
|
|
50
|
+
os.fsync(descriptor)
|
|
51
|
+
finally:
|
|
52
|
+
os.close(descriptor)
|
saga2d/_scene_stack.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""SceneStack — scene transitions with deferred application.
|
|
2
|
+
|
|
3
|
+
Operations requested while the game loop is dispatching input or updating
|
|
4
|
+
are queued and applied afterwards, so a scene never mutates the stack
|
|
5
|
+
underneath its own ``update``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import collections
|
|
11
|
+
import logging
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from saga2d.game import Game
|
|
16
|
+
from saga2d.scene import Scene
|
|
17
|
+
|
|
18
|
+
_logger = logging.getLogger(__name__)
|
|
19
|
+
_MAX_FLUSH_ITERATIONS = 1000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SceneStack:
|
|
23
|
+
def __init__(self, game: Game) -> None:
|
|
24
|
+
self._game = game
|
|
25
|
+
self._stack: list[Scene] = []
|
|
26
|
+
self._pending: collections.deque[tuple[str, Scene | None]] = collections.deque()
|
|
27
|
+
self._busy = False # inside a tick phase, a flush, or a lifecycle hook
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def scenes(self) -> list[Scene]:
|
|
31
|
+
return list(self._stack)
|
|
32
|
+
|
|
33
|
+
def top(self) -> Scene | None:
|
|
34
|
+
return self._stack[-1] if self._stack else None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def transition_pending(self) -> bool:
|
|
38
|
+
"""Whether a callback has requested a scene change for the current phase."""
|
|
39
|
+
return bool(self._pending)
|
|
40
|
+
|
|
41
|
+
def base_index(self) -> int:
|
|
42
|
+
"""Index of the lowest scene that must be drawn."""
|
|
43
|
+
i = len(self._stack) - 1
|
|
44
|
+
while i > 0 and self._stack[i].transparent:
|
|
45
|
+
i -= 1
|
|
46
|
+
return i
|
|
47
|
+
|
|
48
|
+
def get_base_scene(self) -> Scene | None:
|
|
49
|
+
return self._stack[self.base_index()] if self._stack else None
|
|
50
|
+
|
|
51
|
+
# -- Public operations -----------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def push(self, scene: Scene) -> None:
|
|
54
|
+
self._request("push", scene)
|
|
55
|
+
|
|
56
|
+
def pop(self) -> None:
|
|
57
|
+
self._request("pop", None)
|
|
58
|
+
|
|
59
|
+
def pop_to(self, scene: Scene) -> None:
|
|
60
|
+
if self.top() is scene and not self._pending:
|
|
61
|
+
return
|
|
62
|
+
self._request("pop_to", scene)
|
|
63
|
+
|
|
64
|
+
def replace(self, scene: Scene) -> None:
|
|
65
|
+
self._request("replace", scene)
|
|
66
|
+
|
|
67
|
+
def clear_and_push(self, scene: Scene) -> None:
|
|
68
|
+
self._request("clear_and_push", scene)
|
|
69
|
+
|
|
70
|
+
def _request(self, kind: str, scene: Scene | None) -> None:
|
|
71
|
+
self._pending.append((kind, scene))
|
|
72
|
+
if not self._busy:
|
|
73
|
+
self.flush()
|
|
74
|
+
|
|
75
|
+
# -- Tick integration ------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def begin_phase(self) -> None:
|
|
78
|
+
self._busy = True
|
|
79
|
+
|
|
80
|
+
def end_phase(self) -> None:
|
|
81
|
+
self._busy = False
|
|
82
|
+
self.flush()
|
|
83
|
+
|
|
84
|
+
def flush(self) -> None:
|
|
85
|
+
if self._busy:
|
|
86
|
+
return
|
|
87
|
+
self._busy = True
|
|
88
|
+
try:
|
|
89
|
+
iterations = 0
|
|
90
|
+
while self._pending and iterations < _MAX_FLUSH_ITERATIONS:
|
|
91
|
+
iterations += 1
|
|
92
|
+
kind, scene = self._pending.popleft()
|
|
93
|
+
getattr(self, f"_apply_{kind}")(scene)
|
|
94
|
+
if self._pending:
|
|
95
|
+
_logger.warning("SceneStack: %d queued operations discarded after %d iterations", len(self._pending), iterations)
|
|
96
|
+
self._pending.clear()
|
|
97
|
+
except BaseException:
|
|
98
|
+
self._pending.clear()
|
|
99
|
+
raise
|
|
100
|
+
finally:
|
|
101
|
+
self._busy = False
|
|
102
|
+
|
|
103
|
+
# -- Application -----------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def _enter(self, scene: Scene) -> None:
|
|
106
|
+
scene.game = self._game
|
|
107
|
+
scene._level = len(self._stack)
|
|
108
|
+
self._stack.append(scene)
|
|
109
|
+
try:
|
|
110
|
+
scene.on_enter()
|
|
111
|
+
except BaseException as enter_error:
|
|
112
|
+
self._stack.pop()
|
|
113
|
+
try:
|
|
114
|
+
scene._release_resources()
|
|
115
|
+
except BaseException as cleanup_error:
|
|
116
|
+
raise enter_error from cleanup_error
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
def _leave(self, scene: Scene) -> None:
|
|
120
|
+
try:
|
|
121
|
+
scene.on_exit()
|
|
122
|
+
finally:
|
|
123
|
+
scene._release_resources()
|
|
124
|
+
|
|
125
|
+
def _apply_push(self, scene: Scene) -> None:
|
|
126
|
+
if self._stack:
|
|
127
|
+
self._stack[-1].on_exit()
|
|
128
|
+
self._enter(scene)
|
|
129
|
+
|
|
130
|
+
def _apply_pop(self, _scene: Scene | None) -> None:
|
|
131
|
+
if not self._stack:
|
|
132
|
+
return
|
|
133
|
+
old = self._stack.pop()
|
|
134
|
+
try:
|
|
135
|
+
self._leave(old)
|
|
136
|
+
finally:
|
|
137
|
+
if self._stack:
|
|
138
|
+
self._stack[-1].on_reveal()
|
|
139
|
+
|
|
140
|
+
def _apply_replace(self, scene: Scene) -> None:
|
|
141
|
+
if self._stack:
|
|
142
|
+
self._leave(self._stack.pop())
|
|
143
|
+
self._enter(scene)
|
|
144
|
+
|
|
145
|
+
def _apply_pop_to(self, scene: Scene) -> None:
|
|
146
|
+
target = next((i for i, item in enumerate(self._stack) if item is scene), None)
|
|
147
|
+
if target is None:
|
|
148
|
+
raise ValueError("pop_to target is not on the scene stack")
|
|
149
|
+
if target == len(self._stack) - 1:
|
|
150
|
+
return
|
|
151
|
+
first_error: BaseException | None = None
|
|
152
|
+
while len(self._stack) > target + 1:
|
|
153
|
+
try:
|
|
154
|
+
self._leave(self._stack.pop())
|
|
155
|
+
except BaseException as exc:
|
|
156
|
+
first_error = first_error or exc
|
|
157
|
+
scene.on_reveal()
|
|
158
|
+
if first_error is not None:
|
|
159
|
+
raise first_error
|
|
160
|
+
|
|
161
|
+
def _apply_clear_and_push(self, scene: Scene) -> None:
|
|
162
|
+
self.clear()
|
|
163
|
+
self._enter(scene)
|
|
164
|
+
|
|
165
|
+
def clear(self) -> None:
|
|
166
|
+
"""Remove every scene, even if an exit hook fails (used by teardown)."""
|
|
167
|
+
first_error: BaseException | None = None
|
|
168
|
+
while self._stack:
|
|
169
|
+
try:
|
|
170
|
+
self._leave(self._stack.pop())
|
|
171
|
+
except BaseException as exc:
|
|
172
|
+
first_error = first_error or exc
|
|
173
|
+
if first_error is not None:
|
|
174
|
+
raise first_error
|
|
175
|
+
|
|
176
|
+
# -- Per-frame -------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def update(self, dt: float) -> None:
|
|
179
|
+
if not self._stack:
|
|
180
|
+
return
|
|
181
|
+
i = len(self._stack) - 1
|
|
182
|
+
while i > 0 and not self._stack[i].pause_below:
|
|
183
|
+
i -= 1
|
|
184
|
+
for scene in self._stack[i:]:
|
|
185
|
+
scene.update(dt)
|
|
186
|
+
|
|
187
|
+
def draw(self) -> None:
|
|
188
|
+
for scene in self._stack[self.base_index():]:
|
|
189
|
+
scene.draw()
|
|
190
|
+
if scene._ui is not None:
|
|
191
|
+
scene._ui._ensure_layout()
|
|
192
|
+
scene._ui.draw()
|