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.
Files changed (73) hide show
  1. {pygkit-1.2.1 → pygkit-2.1.0}/PKG-INFO +7 -4
  2. {pygkit-1.2.1 → pygkit-2.1.0}/README.md +4 -2
  3. {pygkit-1.2.1 → pygkit-2.1.0}/pyproject.toml +3 -2
  4. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/__init__.py +8 -0
  5. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/animation/player.py +29 -24
  6. pygkit-2.1.0/src/pygkit/audio/__init__.py +7 -0
  7. pygkit-2.1.0/src/pygkit/audio/soundmanager.py +262 -0
  8. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/inventory/core.py +16 -6
  9. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/inventory/ui.py +27 -14
  10. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/lighting/lightmap.py +8 -2
  11. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/lighting/lights.py +266 -53
  12. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/lighting/sprites.py +11 -1
  13. pygkit-2.1.0/src/pygkit/parallax/__init__.py +8 -0
  14. pygkit-2.1.0/src/pygkit/parallax/background.py +139 -0
  15. pygkit-2.1.0/src/pygkit/parallax/camera.py +182 -0
  16. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/protocols.py +3 -3
  17. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/signals/core.py +34 -8
  18. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/transitions/base.py +38 -2
  19. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/transitions/effects.py +59 -25
  20. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/transitions/fades.py +44 -28
  21. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/transitions/wipes.py +46 -25
  22. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/base.py +5 -5
  23. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/button.py +21 -15
  24. pygkit-2.1.0/src/pygkit/ui/container.py +54 -0
  25. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/cooldown.py +11 -6
  26. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/dialog.py +46 -45
  27. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/menubar.py +13 -12
  28. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/progressbar.py +3 -3
  29. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/utils/interpolation.py +3 -3
  30. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/utils/text.py +8 -11
  31. pygkit-2.1.0/src/pygkit/utils/timer.py +70 -0
  32. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit.egg-info/PKG-INFO +7 -4
  33. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit.egg-info/SOURCES.txt +7 -0
  34. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit.egg-info/requires.txt +3 -1
  35. {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_animation.py +25 -16
  36. pygkit-2.1.0/tests/test_audio.py +200 -0
  37. {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_button.py +16 -0
  38. pygkit-2.1.0/tests/test_container.py +69 -0
  39. pygkit-2.1.0/tests/test_cooldown.py +42 -0
  40. {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_dialog.py +24 -1
  41. {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_interpolation.py +17 -8
  42. {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_inventory.py +176 -0
  43. {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_lighting.py +66 -0
  44. {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_packaging.py +1 -1
  45. pygkit-2.1.0/tests/test_parallax.py +280 -0
  46. {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_signals.py +21 -5
  47. {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_text.py +1 -1
  48. pygkit-2.1.0/tests/test_timer.py +112 -0
  49. {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_transitions.py +37 -1
  50. pygkit-1.2.1/src/pygkit/audio/__init__.py +0 -6
  51. pygkit-1.2.1/src/pygkit/audio/soundmanager.py +0 -89
  52. pygkit-1.2.1/src/pygkit/ui/container.py +0 -21
  53. pygkit-1.2.1/src/pygkit/utils/timer.py +0 -35
  54. pygkit-1.2.1/tests/test_timer.py +0 -42
  55. {pygkit-1.2.1 → pygkit-2.1.0}/LICENSE +0 -0
  56. {pygkit-1.2.1 → pygkit-2.1.0}/setup.cfg +0 -0
  57. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/animation/__init__.py +0 -0
  58. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/animation/sheet.py +0 -0
  59. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/inventory/__init__.py +0 -0
  60. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/inventory/assets.py +0 -0
  61. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/inventory/data.py +0 -0
  62. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/lighting/__init__.py +0 -0
  63. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/lighting/blend.py +0 -0
  64. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/signals/__init__.py +0 -0
  65. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/transitions/__init__.py +0 -0
  66. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/__init__.py +0 -0
  67. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/ui/anchors.py +0 -0
  68. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit/utils/__init__.py +0 -0
  69. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit.egg-info/dependency_links.txt +0 -0
  70. {pygkit-1.2.1 → pygkit-2.1.0}/src/pygkit.egg-info/top_level.txt +0 -0
  71. {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_anchors.py +0 -0
  72. {pygkit-1.2.1 → pygkit-2.1.0}/tests/test_box_model.py +0 -0
  73. {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: 1.2.1
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 and explicit targeting
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 and explicit targeting
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 = "1.2.1"
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(dt_ms)`` each frame and blit ``image``.
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 = 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 — use :meth:`reset`.
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 None else on_finish,
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, dt_ms: float) -> None:
118
- """Advance playback by *dt_ms* milliseconds of wall time."""
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 = 1000.0 / state.fps
124
- self._elapsed_in_frame += max(0.0, dt_ms)
125
- while self._elapsed_in_frame >= frame_duration:
126
- self._elapsed_in_frame -= frame_duration
127
- next_index = self._frame_index + 1
128
- if next_index < frame_count:
129
- self._frame_index = next_index
130
- continue
131
- if state.loop:
132
- self._frame_index = 0
133
- else:
134
- self._frame_index = frame_count - 1
135
- self._elapsed_in_frame = 0.0
136
- self._finished = True
137
- if state.on_finish is not None:
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,7 @@
1
+ from .soundmanager import DEFAULT_SFX_CHANNELS, SFX_CHANNELS, SoundManager
2
+
3
+ __all__ = [
4
+ "SoundManager",
5
+ "SFX_CHANNELS",
6
+ "DEFAULT_SFX_CHANNELS",
7
+ ]
@@ -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 source_stack.is_empty()
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
- empty_slot = self.find_empty_slot()
487
- if empty_slot == -1:
488
- return None
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
- target_stack.add_quantity(to_move)
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
- target_stack.add_quantity(to_move)
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
- """Toggle inventory visibility (to be handled by parent manager)."""
609
- pass
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