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.
Files changed (64) hide show
  1. saga2d/__init__.py +43 -0
  2. saga2d/_fileio.py +52 -0
  3. saga2d/_scene_stack.py +192 -0
  4. saga2d/actions.py +496 -0
  5. saga2d/animation.py +202 -0
  6. saga2d/assets/fonts/Nunito-ExtraBold.ttf +0 -0
  7. saga2d/assets/fonts/Nunito-SemiBold.ttf +0 -0
  8. saga2d/assets/fonts/Nunito.ttf +0 -0
  9. saga2d/assets/fonts/OFL.txt +93 -0
  10. saga2d/assets.py +114 -0
  11. saga2d/audio.py +177 -0
  12. saga2d/backends/__init__.py +3 -0
  13. saga2d/backends/base.py +263 -0
  14. saga2d/backends/mock_backend.py +298 -0
  15. saga2d/backends/pyglet_backend.py +797 -0
  16. saga2d/effects.py +398 -0
  17. saga2d/fonts.py +29 -0
  18. saga2d/game.py +512 -0
  19. saga2d/hexgrid.py +138 -0
  20. saga2d/input.py +131 -0
  21. saga2d/multiplayer_ui.py +362 -0
  22. saga2d/network.py +264 -0
  23. saga2d/online.py +253 -0
  24. saga2d/packaging/__init__.py +205 -0
  25. saga2d/packaging/entry.py +40 -0
  26. saga2d/packaging/game.iss +39 -0
  27. saga2d/packaging/game.spec +22 -0
  28. saga2d/packaging/install.py +82 -0
  29. saga2d/packaging/verify.py +212 -0
  30. saga2d/release.py +95 -0
  31. saga2d/rendering/__init__.py +6 -0
  32. saga2d/rendering/_text.py +58 -0
  33. saga2d/rendering/camera.py +351 -0
  34. saga2d/rendering/layers.py +60 -0
  35. saga2d/rendering/particles.py +177 -0
  36. saga2d/rendering/shapes.py +81 -0
  37. saga2d/rendering/sprite.py +390 -0
  38. saga2d/save.py +231 -0
  39. saga2d/scene.py +387 -0
  40. saga2d/server/__init__.py +412 -0
  41. saga2d/server/__main__.py +52 -0
  42. saga2d/server/games.py +73 -0
  43. saga2d/server/storage.py +89 -0
  44. saga2d/settings.py +184 -0
  45. saga2d/testing/__init__.py +190 -0
  46. saga2d/testing/cpu_budget.py +33 -0
  47. saga2d/testing/fixtures.py +18 -0
  48. saga2d/testing/native_frames.py +15 -0
  49. saga2d/testing/online.py +132 -0
  50. saga2d/ui/__init__.py +12 -0
  51. saga2d/ui/base.py +346 -0
  52. saga2d/ui/components.py +570 -0
  53. saga2d/ui/layout.py +75 -0
  54. saga2d/ui/minimap.py +97 -0
  55. saga2d/ui/theme.py +182 -0
  56. saga2d/util/__init__.py +1 -0
  57. saga2d/util/collision.py +51 -0
  58. saga2d/util/reactive.py +112 -0
  59. saga2d/util/timer.py +60 -0
  60. saga2d/util/tween.py +217 -0
  61. saga2d-0.2.0.dist-info/METADATA +138 -0
  62. saga2d-0.2.0.dist-info/RECORD +64 -0
  63. saga2d-0.2.0.dist-info/WHEEL +4 -0
  64. 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()