pygkit 1.2.1__tar.gz → 2.1.0__tar.gz
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.
- {pygkit-1.2.1 → pygkit-2.1.0}/PKG-INFO +7 -4
- {pygkit-1.2.1 → pygkit-2.1.0}/README.md +4 -2
- {pygkit-1.2.1 → pygkit-2.1.0}/pyproject.toml +3 -2
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/__init__.py +8 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/animation/player.py +29 -24
- pygkit-2.1.0/src/pygkit/audio/__init__.py +7 -0
- pygkit-2.1.0/src/pygkit/audio/soundmanager.py +262 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/inventory/core.py +16 -6
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/inventory/ui.py +27 -14
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/lighting/lightmap.py +8 -2
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/lighting/lights.py +266 -53
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/lighting/sprites.py +11 -1
- pygkit-2.1.0/src/pygkit/parallax/__init__.py +8 -0
- pygkit-2.1.0/src/pygkit/parallax/background.py +139 -0
- pygkit-2.1.0/src/pygkit/parallax/camera.py +182 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/protocols.py +3 -3
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/signals/core.py +34 -8
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/transitions/base.py +38 -2
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/transitions/effects.py +59 -25
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/transitions/fades.py +44 -28
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/transitions/wipes.py +46 -25
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/base.py +5 -5
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/button.py +21 -15
- pygkit-2.1.0/src/pygkit/ui/container.py +54 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/cooldown.py +11 -6
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/dialog.py +46 -45
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/menubar.py +13 -12
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/progressbar.py +3 -3
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/utils/interpolation.py +3 -3
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/utils/text.py +8 -11
- pygkit-2.1.0/src/pygkit/utils/timer.py +70 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit.egg-info/PKG-INFO +7 -4
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit.egg-info/SOURCES.txt +7 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit.egg-info/requires.txt +3 -1
- {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_animation.py +25 -16
- pygkit-2.1.0/tests/test_audio.py +200 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_button.py +16 -0
- pygkit-2.1.0/tests/test_container.py +69 -0
- pygkit-2.1.0/tests/test_cooldown.py +42 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_dialog.py +24 -1
- {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_interpolation.py +17 -8
- {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_inventory.py +176 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_lighting.py +66 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_packaging.py +1 -1
- pygkit-2.1.0/tests/test_parallax.py +280 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_signals.py +21 -5
- {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_text.py +1 -1
- pygkit-2.1.0/tests/test_timer.py +112 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_transitions.py +37 -1
- pygkit-1.2.1/src/pygkit/audio/__init__.py +0 -6
- pygkit-1.2.1/src/pygkit/audio/soundmanager.py +0 -89
- pygkit-1.2.1/src/pygkit/ui/container.py +0 -21
- pygkit-1.2.1/src/pygkit/utils/timer.py +0 -35
- pygkit-1.2.1/tests/test_timer.py +0 -42
- {pygkit-1.2.1 → pygkit-2.1.0}/LICENSE +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/setup.cfg +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/animation/__init__.py +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/animation/sheet.py +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/inventory/__init__.py +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/inventory/assets.py +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/inventory/data.py +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/lighting/__init__.py +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/lighting/blend.py +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/signals/__init__.py +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/transitions/__init__.py +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/__init__.py +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/anchors.py +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/utils/__init__.py +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit.egg-info/dependency_links.txt +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit.egg-info/top_level.txt +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_anchors.py +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_box_model.py +0 -0
- {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_menubar.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pygkit
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: General-purpose Pygame CE runtime kit: UI widgets, audio, timers, interpolation, and more.
|
|
5
5
|
Author: pygkit contributors
|
|
6
6
|
License: GNU GENERAL PUBLIC LICENSE
|
|
@@ -700,8 +700,9 @@ Provides-Extra: animation
|
|
|
700
700
|
Provides-Extra: lighting
|
|
701
701
|
Provides-Extra: transitions
|
|
702
702
|
Provides-Extra: inventory
|
|
703
|
+
Provides-Extra: parallax
|
|
703
704
|
Provides-Extra: all
|
|
704
|
-
Requires-Dist: pygkit[animation,audio,inventory,lighting,transitions,ui]; extra == "all"
|
|
705
|
+
Requires-Dist: pygkit[animation,audio,inventory,lighting,parallax,transitions,ui]; extra == "all"
|
|
705
706
|
Dynamic: license-file
|
|
706
707
|
|
|
707
708
|
# pygkit
|
|
@@ -720,7 +721,7 @@ import pygkit
|
|
|
720
721
|
- **Animation**: Multi-state `AnimationPlayer` where every state owns its own spritesheet — mixed image sizes and grids (e.g. `idle_spritesheet.png` vs `run_spritesheet.png`) in one player; pure image API, no JSON
|
|
721
722
|
- **Dialog**: Typewriter effect with customizable speeds, speaker names, and progress indicators
|
|
722
723
|
- **Inventory**: Grid-based item management with stacking, weight tracking, events, and serialization
|
|
723
|
-
- **Audio**: SoundManager with channel pools
|
|
724
|
+
- **Audio**: SoundManager with channel pools, kind/key addressing, volume buses, fades
|
|
724
725
|
- **Lighting**: low-resolution light map pipeline (`LightMap`), point lights
|
|
725
726
|
with flicker, wind-reactive `DynamicLight` flames, and cone `Spotlight` —
|
|
726
727
|
full RGB/RGBA blend modes, allocation-free per frame
|
|
@@ -736,5 +737,7 @@ pip install "pygkit[all]" # everything
|
|
|
736
737
|
```
|
|
737
738
|
|
|
738
739
|
Available extras: `ui`, `audio`, `animation`, `lighting`,
|
|
739
|
-
`transitions`, `inventory`, `all`. `import pygkit` alone loads
|
|
740
|
+
`transitions`, `inventory`, `parallax`, `all`. `import pygkit` alone loads
|
|
740
741
|
core only; subsystems load lazily on first use.
|
|
742
|
+
|
|
743
|
+
> Extra: lightning idea was extended version of [https://www.youtube.com/watch?v=LN7u20H7zK4](https://www.youtube.com/watch?v=LN7u20H7zK4) which was my first attempt of making lightning after learning blend modes.
|
|
@@ -14,7 +14,7 @@ import pygkit
|
|
|
14
14
|
- **Animation**: Multi-state `AnimationPlayer` where every state owns its own spritesheet — mixed image sizes and grids (e.g. `idle_spritesheet.png` vs `run_spritesheet.png`) in one player; pure image API, no JSON
|
|
15
15
|
- **Dialog**: Typewriter effect with customizable speeds, speaker names, and progress indicators
|
|
16
16
|
- **Inventory**: Grid-based item management with stacking, weight tracking, events, and serialization
|
|
17
|
-
- **Audio**: SoundManager with channel pools
|
|
17
|
+
- **Audio**: SoundManager with channel pools, kind/key addressing, volume buses, fades
|
|
18
18
|
- **Lighting**: low-resolution light map pipeline (`LightMap`), point lights
|
|
19
19
|
with flicker, wind-reactive `DynamicLight` flames, and cone `Spotlight` —
|
|
20
20
|
full RGB/RGBA blend modes, allocation-free per frame
|
|
@@ -30,5 +30,7 @@ pip install "pygkit[all]" # everything
|
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
Available extras: `ui`, `audio`, `animation`, `lighting`,
|
|
33
|
-
`transitions`, `inventory`, `all`. `import pygkit` alone loads
|
|
33
|
+
`transitions`, `inventory`, `parallax`, `all`. `import pygkit` alone loads
|
|
34
34
|
core only; subsystems load lazily on first use.
|
|
35
|
+
|
|
36
|
+
> Extra: lightning idea was extended version of [https://www.youtube.com/watch?v=LN7u20H7zK4](https://www.youtube.com/watch?v=LN7u20H7zK4) which was my first attempt of making lightning after learning blend modes.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pygkit"
|
|
7
|
-
version = "
|
|
7
|
+
version = "2.1.0"
|
|
8
8
|
description = "General-purpose Pygame CE runtime kit: UI widgets, audio, timers, interpolation, and more."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -37,8 +37,9 @@ animation = []
|
|
|
37
37
|
lighting = []
|
|
38
38
|
transitions = []
|
|
39
39
|
inventory = []
|
|
40
|
+
parallax = []
|
|
40
41
|
all = [
|
|
41
|
-
"pygkit[ui, audio, animation, lighting, transitions, inventory]",
|
|
42
|
+
"pygkit[ui, audio, animation, lighting, transitions, inventory, parallax]",
|
|
42
43
|
]
|
|
43
44
|
|
|
44
45
|
[project.urls]
|
|
@@ -34,6 +34,10 @@ from .utils import (
|
|
|
34
34
|
_LAZY_ATTRS: dict[str, str] = {
|
|
35
35
|
"SoundManager": "audio",
|
|
36
36
|
"SFX_CHANNELS": "audio",
|
|
37
|
+
"DEFAULT_SFX_CHANNELS": "audio",
|
|
38
|
+
"ParallaxBackground": "parallax",
|
|
39
|
+
"ParallaxLayer": "parallax",
|
|
40
|
+
"Camera2D": "parallax",
|
|
37
41
|
"AnimationPlayer": "animation",
|
|
38
42
|
"AnimationSheet": "animation",
|
|
39
43
|
"PackOptions": "animation",
|
|
@@ -111,6 +115,10 @@ _LAZY_ATTRS: dict[str, str] = {
|
|
|
111
115
|
__all__ = [
|
|
112
116
|
"SoundManager",
|
|
113
117
|
"SFX_CHANNELS",
|
|
118
|
+
"DEFAULT_SFX_CHANNELS",
|
|
119
|
+
"ParallaxBackground",
|
|
120
|
+
"ParallaxLayer",
|
|
121
|
+
"Camera2D",
|
|
114
122
|
"AnimationPlayer",
|
|
115
123
|
"AnimationSheet",
|
|
116
124
|
"PackOptions",
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import math
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
from typing import Callable
|
|
5
6
|
|
|
6
7
|
from .sheet import AnimationSheet
|
|
7
8
|
|
|
9
|
+
_UNSET: object = object()
|
|
10
|
+
|
|
8
11
|
|
|
9
12
|
@dataclass
|
|
10
13
|
class _State:
|
|
@@ -19,7 +22,7 @@ class AnimationPlayer:
|
|
|
19
22
|
|
|
20
23
|
States are registered lazily via :meth:`play`; sheets may differ freely in
|
|
21
24
|
pixel size and grid layout between states. Playback is time-based: feed
|
|
22
|
-
``update(
|
|
25
|
+
``update(dt)`` each frame with delta time in seconds and blit ``image``.
|
|
23
26
|
"""
|
|
24
27
|
|
|
25
28
|
__slots__ = (
|
|
@@ -89,13 +92,15 @@ class AnimationPlayer:
|
|
|
89
92
|
*,
|
|
90
93
|
fps: float | None = None,
|
|
91
94
|
loop: bool | None = None,
|
|
92
|
-
on_finish: Callable[[], None] | None =
|
|
95
|
+
on_finish: Callable[[], None] | None | object = _UNSET,
|
|
96
|
+
restart: bool = False,
|
|
93
97
|
) -> None:
|
|
94
98
|
"""Switch to *name*, registering the state on first use.
|
|
95
99
|
|
|
96
100
|
Registration requires *sheet*; omitted options fall back to the
|
|
97
101
|
player defaults given at construction. Calling ``play`` for the state
|
|
98
|
-
already playing does not restart it
|
|
102
|
+
already playing does not restart it unless ``restart`` is true.
|
|
103
|
+
Pass ``on_finish=None`` explicitly to clear a default callback.
|
|
99
104
|
"""
|
|
100
105
|
if name not in self._states:
|
|
101
106
|
if sheet is None:
|
|
@@ -106,37 +111,37 @@ class AnimationPlayer:
|
|
|
106
111
|
sheet=sheet,
|
|
107
112
|
fps=float(resolved_fps),
|
|
108
113
|
loop=self._default_loop if loop is None else bool(loop),
|
|
109
|
-
on_finish=self._default_on_finish if on_finish is
|
|
114
|
+
on_finish=self._default_on_finish if on_finish is _UNSET else on_finish,
|
|
110
115
|
)
|
|
111
116
|
|
|
112
|
-
if name == self._state_name:
|
|
117
|
+
if name == self._state_name and not restart:
|
|
113
118
|
return
|
|
114
119
|
self._state_name = name
|
|
115
120
|
self.reset()
|
|
116
121
|
|
|
117
|
-
def update(self,
|
|
118
|
-
"""Advance playback by *
|
|
122
|
+
def update(self, dt: float) -> None:
|
|
123
|
+
"""Advance playback by *dt* seconds."""
|
|
119
124
|
if self._paused or self._state_name is None or self._finished:
|
|
120
125
|
return
|
|
126
|
+
if not math.isfinite(dt) or dt < 0:
|
|
127
|
+
dt = 0.0
|
|
121
128
|
state = self._states[self._state_name]
|
|
122
129
|
frame_count = state.sheet.frame_count
|
|
123
|
-
frame_duration =
|
|
124
|
-
self._elapsed_in_frame
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
state.on_finish()
|
|
139
|
-
break
|
|
130
|
+
frame_duration = 1.0 / state.fps
|
|
131
|
+
steps, self._elapsed_in_frame = divmod(self._elapsed_in_frame + dt, frame_duration)
|
|
132
|
+
steps = int(steps)
|
|
133
|
+
if steps <= 0:
|
|
134
|
+
return
|
|
135
|
+
if state.loop:
|
|
136
|
+
self._frame_index = (self._frame_index + steps) % frame_count
|
|
137
|
+
elif self._frame_index + steps >= frame_count:
|
|
138
|
+
self._frame_index = frame_count - 1
|
|
139
|
+
self._elapsed_in_frame = 0.0
|
|
140
|
+
self._finished = True
|
|
141
|
+
if state.on_finish is not None:
|
|
142
|
+
state.on_finish()
|
|
143
|
+
else:
|
|
144
|
+
self._frame_index += steps
|
|
140
145
|
|
|
141
146
|
def pause(self) -> None:
|
|
142
147
|
self._paused = True
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Dict, List, Literal, Optional, Tuple
|
|
3
|
+
|
|
4
|
+
import pygame
|
|
5
|
+
from pygame import Channel, Sound
|
|
6
|
+
|
|
7
|
+
DEFAULT_SFX_CHANNELS = 6
|
|
8
|
+
SFX_CHANNELS = DEFAULT_SFX_CHANNELS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _clamp(value: float) -> float:
|
|
12
|
+
return max(0.0, min(1.0, float(value)))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SoundManager:
|
|
16
|
+
def __init__(self, num_channels: int = 8, main_channels: int = 2) -> None:
|
|
17
|
+
if pygame.mixer.get_init() is None:
|
|
18
|
+
raise RuntimeError("pygame.mixer not initialized")
|
|
19
|
+
if num_channels < 1:
|
|
20
|
+
raise ValueError(f"num_channels must be >= 1, got {num_channels!r}")
|
|
21
|
+
if not 0 <= main_channels <= num_channels:
|
|
22
|
+
raise ValueError(f"main_channels must be 0..num_channels, got {main_channels!r}")
|
|
23
|
+
|
|
24
|
+
if num_channels - main_channels < DEFAULT_SFX_CHANNELS:
|
|
25
|
+
num_channels = main_channels + DEFAULT_SFX_CHANNELS
|
|
26
|
+
|
|
27
|
+
if pygame.mixer.get_num_channels() < num_channels:
|
|
28
|
+
pygame.mixer.set_num_channels(num_channels)
|
|
29
|
+
|
|
30
|
+
self._main: Dict[str, Sound] = {}
|
|
31
|
+
self._sfx: Dict[str, Sound] = {}
|
|
32
|
+
|
|
33
|
+
self._num_channels = num_channels
|
|
34
|
+
self._main_count = main_channels
|
|
35
|
+
self._channels = tuple(pygame.Channel(i) for i in range(num_channels))
|
|
36
|
+
self._next_sfx = main_channels
|
|
37
|
+
self._next_main = 0
|
|
38
|
+
|
|
39
|
+
self._master = 1.0
|
|
40
|
+
self._kind_vol: Dict[str, float] = {"main": 1.0, "sfx": 1.0}
|
|
41
|
+
self._vol: Dict[Tuple[str, str], float] = {}
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def sfx_channels(self) -> int:
|
|
45
|
+
return self._num_channels - self._main_count
|
|
46
|
+
|
|
47
|
+
def add_sound(
|
|
48
|
+
self, path: str | Path, key: str, kind: Literal["main", "sfx"] = "sfx",
|
|
49
|
+
volume: float = 1.0,
|
|
50
|
+
) -> bool:
|
|
51
|
+
try:
|
|
52
|
+
scope = self._scope(kind)
|
|
53
|
+
except ValueError:
|
|
54
|
+
return False
|
|
55
|
+
registry = self._main if scope == "main" else self._sfx
|
|
56
|
+
try:
|
|
57
|
+
registry[key] = pygame.Sound(str(path))
|
|
58
|
+
self._vol[(scope, key)] = _clamp(volume)
|
|
59
|
+
return True
|
|
60
|
+
except (FileNotFoundError, TypeError, pygame.error):
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
def _scope(self, kind: str) -> str:
|
|
64
|
+
if kind == "main":
|
|
65
|
+
return "main"
|
|
66
|
+
if kind == "sfx":
|
|
67
|
+
return "sfx"
|
|
68
|
+
raise ValueError(f"kind must be 'main' or 'sfx', got {kind!r}")
|
|
69
|
+
|
|
70
|
+
def _pool(self, kind: str):
|
|
71
|
+
scope = self._scope(kind)
|
|
72
|
+
start = 0 if scope == "main" else self._main_count
|
|
73
|
+
stop = self._main_count if scope == "main" else self._num_channels
|
|
74
|
+
for idx in range(start, stop):
|
|
75
|
+
yield idx, self._channels[idx]
|
|
76
|
+
|
|
77
|
+
def _check_channel_id(self, channel_id: int) -> Channel:
|
|
78
|
+
if not isinstance(channel_id, int) or isinstance(channel_id, bool):
|
|
79
|
+
raise ValueError(f"channel_id must be an int, got {channel_id!r}")
|
|
80
|
+
if not 0 <= channel_id < self._num_channels:
|
|
81
|
+
raise ValueError(f"channel_id out of range 0..{self._num_channels - 1}")
|
|
82
|
+
return self._channels[channel_id]
|
|
83
|
+
|
|
84
|
+
def _effective(self, kind: str, key: str) -> float:
|
|
85
|
+
scope = self._scope(kind)
|
|
86
|
+
return _clamp(self._master * self._kind_vol[scope] * self._vol.get((scope, key), 1.0))
|
|
87
|
+
|
|
88
|
+
def _locate(self, channel: Channel) -> Optional[Tuple[str, str]]:
|
|
89
|
+
if not channel.get_busy():
|
|
90
|
+
return None
|
|
91
|
+
sound = channel.get_sound() if hasattr(channel, "get_sound") else None
|
|
92
|
+
if sound is None:
|
|
93
|
+
return None
|
|
94
|
+
for scope, registry in (("main", self._main), ("sfx", self._sfx)):
|
|
95
|
+
for key, owned in registry.items():
|
|
96
|
+
if owned is sound:
|
|
97
|
+
return (scope, key)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def play(
|
|
101
|
+
self,
|
|
102
|
+
key: str,
|
|
103
|
+
kind: Literal["main", "sfx"] = "sfx",
|
|
104
|
+
channel_id: int | None = None,
|
|
105
|
+
loops: int | None = None,
|
|
106
|
+
volume: float | None = None,
|
|
107
|
+
fade_ms: int = 0,
|
|
108
|
+
) -> Channel:
|
|
109
|
+
scope = self._scope(kind)
|
|
110
|
+
registry = self._main if scope == "main" else self._sfx
|
|
111
|
+
sound = registry.get(key)
|
|
112
|
+
if sound is None:
|
|
113
|
+
raise KeyError(f"Unknown sound '{key}'")
|
|
114
|
+
|
|
115
|
+
if loops is None:
|
|
116
|
+
loops = -1 if scope == "main" else 0
|
|
117
|
+
|
|
118
|
+
if volume is not None:
|
|
119
|
+
self._vol[(scope, key)] = _clamp(volume)
|
|
120
|
+
|
|
121
|
+
fade_ms = max(0, int(fade_ms))
|
|
122
|
+
if channel_id is not None:
|
|
123
|
+
channel = self._check_channel_id(channel_id)
|
|
124
|
+
channel.stop()
|
|
125
|
+
channel.play(sound, loops, 0, fade_ms)
|
|
126
|
+
channel.set_volume(self._effective(scope, key))
|
|
127
|
+
return channel
|
|
128
|
+
|
|
129
|
+
channel = self._assign(scope)
|
|
130
|
+
channel.play(sound, loops, 0, fade_ms)
|
|
131
|
+
channel.set_volume(self._effective(scope, key))
|
|
132
|
+
return channel
|
|
133
|
+
|
|
134
|
+
def channels_for(self, key: str, kind: Literal["main", "sfx"] = "sfx") -> List[Channel]:
|
|
135
|
+
scope = self._scope(kind)
|
|
136
|
+
registry = self._main if scope == "main" else self._sfx
|
|
137
|
+
sound = registry.get(key)
|
|
138
|
+
if sound is None:
|
|
139
|
+
raise KeyError(f"Unknown sound '{key}'")
|
|
140
|
+
return [
|
|
141
|
+
channel
|
|
142
|
+
for _, channel in self._pool(scope)
|
|
143
|
+
if channel.get_busy() and channel.get_sound() is sound
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
def playing(self, key: str, kind: Literal["main", "sfx"] = "sfx") -> bool:
|
|
147
|
+
return len(self.channels_for(key, kind)) > 0
|
|
148
|
+
|
|
149
|
+
def channel_for(self, key: str, kind: Literal["main", "sfx"] = "sfx") -> Optional[Channel]:
|
|
150
|
+
found = self.channels_for(key, kind)
|
|
151
|
+
return found[0] if found else None
|
|
152
|
+
|
|
153
|
+
def stop(self, kind: Literal["main", "sfx"] | None = None, key: str | None = None) -> None:
|
|
154
|
+
if kind is None and key is None:
|
|
155
|
+
raise ValueError("stop() needs a kind or use stop_all()")
|
|
156
|
+
if key is not None:
|
|
157
|
+
if kind is None:
|
|
158
|
+
raise ValueError("stop(key=...) needs kind=...")
|
|
159
|
+
for channel in self.channels_for(key, kind):
|
|
160
|
+
channel.stop()
|
|
161
|
+
return
|
|
162
|
+
scope = self._scope(kind)
|
|
163
|
+
for _, channel in self._pool(scope):
|
|
164
|
+
channel.stop()
|
|
165
|
+
|
|
166
|
+
def stop_all(self) -> None:
|
|
167
|
+
for ch in self._channels:
|
|
168
|
+
ch.stop()
|
|
169
|
+
|
|
170
|
+
def stop_channel(self, channel_id: int) -> None:
|
|
171
|
+
self._check_channel_id(channel_id).stop()
|
|
172
|
+
|
|
173
|
+
def fadeout(
|
|
174
|
+
self, time_ms: int, kind: Literal["main", "sfx"] | None = None, key: str | None = None
|
|
175
|
+
) -> None:
|
|
176
|
+
time_ms = max(0, int(time_ms))
|
|
177
|
+
if key is not None:
|
|
178
|
+
if kind is None:
|
|
179
|
+
raise ValueError("fadeout(key=...) needs kind=...")
|
|
180
|
+
for channel in self.channels_for(key, kind):
|
|
181
|
+
channel.fadeout(time_ms)
|
|
182
|
+
return
|
|
183
|
+
if kind is None:
|
|
184
|
+
for ch in self._channels:
|
|
185
|
+
if ch.get_busy():
|
|
186
|
+
ch.fadeout(time_ms)
|
|
187
|
+
return
|
|
188
|
+
for _, channel in self._pool(self._scope(kind)):
|
|
189
|
+
if channel.get_busy():
|
|
190
|
+
channel.fadeout(time_ms)
|
|
191
|
+
|
|
192
|
+
def set_volume(
|
|
193
|
+
self, value: float, kind: Literal["main", "sfx"] | None = None, key: str | None = None
|
|
194
|
+
) -> None:
|
|
195
|
+
value = _clamp(value)
|
|
196
|
+
if key is not None:
|
|
197
|
+
if kind is None:
|
|
198
|
+
raise ValueError("set_volume(key=...) needs kind=...")
|
|
199
|
+
scope = self._scope(kind)
|
|
200
|
+
registry = self._main if scope == "main" else self._sfx
|
|
201
|
+
if key not in registry:
|
|
202
|
+
raise KeyError(f"Unknown sound '{key}'")
|
|
203
|
+
self._vol[(scope, key)] = value
|
|
204
|
+
for channel in self.channels_for(key, scope):
|
|
205
|
+
channel.set_volume(self._effective(scope, key))
|
|
206
|
+
return
|
|
207
|
+
if kind is None:
|
|
208
|
+
self._master = value
|
|
209
|
+
for ch in self._channels:
|
|
210
|
+
located = self._locate(ch)
|
|
211
|
+
if located is not None:
|
|
212
|
+
ch.set_volume(self._effective(*located))
|
|
213
|
+
return
|
|
214
|
+
scope = self._scope(kind)
|
|
215
|
+
self._kind_vol[scope] = value
|
|
216
|
+
for _, channel in self._pool(scope):
|
|
217
|
+
located = self._locate(channel)
|
|
218
|
+
if located is not None:
|
|
219
|
+
channel.set_volume(self._effective(*located))
|
|
220
|
+
|
|
221
|
+
def get_volume(
|
|
222
|
+
self, kind: Literal["main", "sfx"] | None = None, key: str | None = None
|
|
223
|
+
) -> float:
|
|
224
|
+
if key is not None:
|
|
225
|
+
if kind is None:
|
|
226
|
+
raise ValueError("get_volume(key=...) needs kind=...")
|
|
227
|
+
scope = self._scope(kind)
|
|
228
|
+
registry = self._main if scope == "main" else self._sfx
|
|
229
|
+
if key not in registry:
|
|
230
|
+
raise KeyError(f"Unknown sound '{key}'")
|
|
231
|
+
return self._vol.get((scope, key), 1.0)
|
|
232
|
+
if kind is None:
|
|
233
|
+
return self._master
|
|
234
|
+
return self._kind_vol[self._scope(kind)]
|
|
235
|
+
|
|
236
|
+
def get_channel(self, channel_id: int) -> Channel:
|
|
237
|
+
return self._check_channel_id(channel_id)
|
|
238
|
+
|
|
239
|
+
def _assign(self, kind: Literal["main", "sfx"]) -> Channel:
|
|
240
|
+
if kind == "main":
|
|
241
|
+
for idx in range(self._main_count):
|
|
242
|
+
ch = self._channels[idx]
|
|
243
|
+
if not ch.get_busy():
|
|
244
|
+
return ch
|
|
245
|
+
if self._main_count == 0:
|
|
246
|
+
raise RuntimeError("no main channels allocated")
|
|
247
|
+
ch = self._channels[self._next_main % self._main_count]
|
|
248
|
+
self._next_main += 1
|
|
249
|
+
ch.stop()
|
|
250
|
+
return ch
|
|
251
|
+
|
|
252
|
+
for idx in range(self._main_count, self._num_channels):
|
|
253
|
+
ch = self._channels[idx]
|
|
254
|
+
if not ch.get_busy():
|
|
255
|
+
return ch
|
|
256
|
+
|
|
257
|
+
ch = self._channels[self._next_sfx]
|
|
258
|
+
ch.stop()
|
|
259
|
+
self._next_sfx += 1
|
|
260
|
+
if self._next_sfx >= self._num_channels:
|
|
261
|
+
self._next_sfx = self._main_count
|
|
262
|
+
return ch
|
|
@@ -334,7 +334,7 @@ class Inventory:
|
|
|
334
334
|
)
|
|
335
335
|
else:
|
|
336
336
|
stack.remove_quantity(quantity)
|
|
337
|
-
removed_stack = ItemStack(data=stack.data, quantity=quantity)
|
|
337
|
+
removed_stack = ItemStack(data=dict(stack.data), quantity=quantity)
|
|
338
338
|
self._emit_event(
|
|
339
339
|
InventoryEvent(
|
|
340
340
|
event_type="item_removed",
|
|
@@ -410,7 +410,7 @@ class Inventory:
|
|
|
410
410
|
)
|
|
411
411
|
|
|
412
412
|
self._update_weight()
|
|
413
|
-
return
|
|
413
|
+
return True
|
|
414
414
|
|
|
415
415
|
return False
|
|
416
416
|
|
|
@@ -462,6 +462,7 @@ class Inventory:
|
|
|
462
462
|
self,
|
|
463
463
|
slot_index: int,
|
|
464
464
|
quantity: int,
|
|
465
|
+
target_slot: Optional[int] = None,
|
|
465
466
|
) -> Optional[ItemStack]:
|
|
466
467
|
"""
|
|
467
468
|
Split a stack, moving part to an empty slot.
|
|
@@ -469,6 +470,8 @@ class Inventory:
|
|
|
469
470
|
Args:
|
|
470
471
|
slot_index: Slot containing the stack to split
|
|
471
472
|
quantity: Amount to move to new slot
|
|
473
|
+
target_slot: Preferred destination (must be empty); falls back
|
|
474
|
+
to the first empty slot when None
|
|
472
475
|
|
|
473
476
|
Returns:
|
|
474
477
|
The new stack created, or None if split failed
|
|
@@ -483,12 +486,19 @@ class Inventory:
|
|
|
483
486
|
if quantity <= 0 or quantity >= source_stack.quantity:
|
|
484
487
|
return None
|
|
485
488
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
+
if target_slot is not None:
|
|
490
|
+
if not 0 <= target_slot < self.total_slots:
|
|
491
|
+
return None
|
|
492
|
+
if target_slot == slot_index or self._slots[target_slot] is not None:
|
|
493
|
+
return None
|
|
494
|
+
empty_slot = target_slot
|
|
495
|
+
else:
|
|
496
|
+
empty_slot = self.find_empty_slot()
|
|
497
|
+
if empty_slot == -1:
|
|
498
|
+
return None
|
|
489
499
|
|
|
490
500
|
new_stack = ItemStack(
|
|
491
|
-
data=source_stack.data,
|
|
501
|
+
data=dict(source_stack.data),
|
|
492
502
|
quantity=quantity,
|
|
493
503
|
)
|
|
494
504
|
source_stack.remove_quantity(quantity)
|
|
@@ -7,7 +7,6 @@ drag-and-drop support, tooltips, and visual feedback.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
import time
|
|
11
10
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
12
11
|
|
|
13
12
|
import pygame
|
|
@@ -181,7 +180,7 @@ class DragDropState:
|
|
|
181
180
|
|
|
182
181
|
if split and stack.quantity > 1:
|
|
183
182
|
half_qty = (stack.quantity + 1) // 2
|
|
184
|
-
self.dragged_stack = ItemStack(data=stack.data, quantity=half_qty)
|
|
183
|
+
self.dragged_stack = ItemStack(data=dict(stack.data), quantity=half_qty)
|
|
185
184
|
else:
|
|
186
185
|
self.dragged_stack = stack.copy()
|
|
187
186
|
|
|
@@ -238,6 +237,7 @@ class InventoryWidget:
|
|
|
238
237
|
self.inventory = inventory
|
|
239
238
|
self.position = position
|
|
240
239
|
self.config = {**DEFAULT_INVENTORY_CONFIG, **(config or {})}
|
|
240
|
+
self.visible = True
|
|
241
241
|
|
|
242
242
|
self.rows = inventory.rows
|
|
243
243
|
self.cols = inventory.cols
|
|
@@ -328,6 +328,8 @@ class InventoryWidget:
|
|
|
328
328
|
|
|
329
329
|
def handle_events(self, events: List[pygame.event.Event]) -> None:
|
|
330
330
|
"""Process pygame events for inventory interaction."""
|
|
331
|
+
if not self.visible:
|
|
332
|
+
return
|
|
331
333
|
mouse_pos = pygame.mouse.get_pos()
|
|
332
334
|
|
|
333
335
|
for event in events:
|
|
@@ -438,12 +440,8 @@ class InventoryWidget:
|
|
|
438
440
|
if self._drag_state.split_mode:
|
|
439
441
|
to_move = min(dragged_stack.quantity, space)
|
|
440
442
|
if to_move > 0:
|
|
441
|
-
|
|
443
|
+
self.inventory.move_item(source_slot, target_slot, to_move)
|
|
442
444
|
dragged_stack.remove_quantity(to_move)
|
|
443
|
-
|
|
444
|
-
if dragged_stack.quantity <= 0:
|
|
445
|
-
self.inventory.remove_from_slot(source_slot)
|
|
446
|
-
|
|
447
445
|
return True
|
|
448
446
|
else:
|
|
449
447
|
to_move = min(dragged_stack.quantity, space)
|
|
@@ -452,14 +450,13 @@ class InventoryWidget:
|
|
|
452
450
|
self.inventory.move_item(source_slot, target_slot)
|
|
453
451
|
return True
|
|
454
452
|
else:
|
|
455
|
-
|
|
453
|
+
self.inventory.move_item(source_slot, target_slot, to_move)
|
|
456
454
|
dragged_stack.remove_quantity(to_move)
|
|
457
|
-
self.inventory.remove_from_slot(source_slot, dragged_stack.quantity)
|
|
458
455
|
return True
|
|
459
456
|
|
|
460
457
|
elif target_stack is None:
|
|
461
458
|
if self._drag_state.split_mode:
|
|
462
|
-
new_stack = self.inventory.split_stack(source_slot, dragged_stack.quantity)
|
|
459
|
+
new_stack = self.inventory.split_stack(source_slot, dragged_stack.quantity, target_slot)
|
|
463
460
|
return new_stack is not None
|
|
464
461
|
else:
|
|
465
462
|
return self.inventory.move_item(source_slot, target_slot)
|
|
@@ -469,8 +466,10 @@ class InventoryWidget:
|
|
|
469
466
|
|
|
470
467
|
return False
|
|
471
468
|
|
|
472
|
-
def update(self, dt: float) -> None:
|
|
469
|
+
def update(self, dt: float = 0.0) -> None:
|
|
473
470
|
"""Update inventory state (tooltip timing, etc.)."""
|
|
471
|
+
if not self.visible:
|
|
472
|
+
return
|
|
474
473
|
if self._hovered_slot is not None and not self._drag_state.is_dragging:
|
|
475
474
|
stack = self.inventory.get_slot(self._hovered_slot)
|
|
476
475
|
if stack:
|
|
@@ -488,6 +487,8 @@ class InventoryWidget:
|
|
|
488
487
|
|
|
489
488
|
def render(self, screen: Surface) -> None:
|
|
490
489
|
"""Render the inventory UI to the screen."""
|
|
490
|
+
if not self.visible:
|
|
491
|
+
return
|
|
491
492
|
|
|
492
493
|
bg_color = self.config.get("background_color", (40, 40, 45, 230))
|
|
493
494
|
border_color = self.config.get("border_color", (100, 100, 110, 255))
|
|
@@ -604,9 +605,16 @@ class InventoryWidget:
|
|
|
604
605
|
"""Move the inventory widget to a new position."""
|
|
605
606
|
self.position = pos
|
|
606
607
|
|
|
608
|
+
def _reset_interaction_state(self) -> None:
|
|
609
|
+
self._drag_state.end_drag()
|
|
610
|
+
self._hovered_slot = None
|
|
611
|
+
self._tooltip.hide()
|
|
612
|
+
self._tooltip_timer = 0.0
|
|
613
|
+
|
|
607
614
|
def toggle_visibility(self) -> None:
|
|
608
|
-
|
|
609
|
-
|
|
615
|
+
self.visible = not self.visible
|
|
616
|
+
if not self.visible:
|
|
617
|
+
self._reset_interaction_state()
|
|
610
618
|
|
|
611
619
|
|
|
612
620
|
class InventoryManager:
|
|
@@ -646,12 +654,17 @@ class InventoryManager:
|
|
|
646
654
|
"""Close/hide an inventory."""
|
|
647
655
|
if inventory_id in self._active_inventories:
|
|
648
656
|
self._active_inventories.remove(inventory_id)
|
|
657
|
+
widget = self._inventories.get(inventory_id)
|
|
658
|
+
if widget is not None:
|
|
659
|
+
widget._reset_interaction_state()
|
|
649
660
|
|
|
650
661
|
if not self._active_inventories:
|
|
651
662
|
self._visible = False
|
|
652
663
|
|
|
653
664
|
def close_all(self) -> None:
|
|
654
665
|
"""Close all open inventories."""
|
|
666
|
+
for widget in self._inventories.values():
|
|
667
|
+
widget._reset_interaction_state()
|
|
655
668
|
self._active_inventories.clear()
|
|
656
669
|
self._visible = False
|
|
657
670
|
|
|
@@ -675,7 +688,7 @@ class InventoryManager:
|
|
|
675
688
|
if inv_id in self._inventories:
|
|
676
689
|
self._inventories[inv_id].handle_events(events)
|
|
677
690
|
|
|
678
|
-
def update(self, dt: float) -> None:
|
|
691
|
+
def update(self, dt: float = 0.0) -> None:
|
|
679
692
|
"""Update all active inventories."""
|
|
680
693
|
if not self._visible:
|
|
681
694
|
return
|