sonolus.py 0.7.0__py3-none-any.whl → 0.8.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.

Potentially problematic release.


This version of sonolus.py might be problematic. Click here for more details.

@@ -14,7 +14,16 @@ from sonolus.backend.utils import get_function, scan_writes
14
14
  from sonolus.script.debug import assert_true
15
15
  from sonolus.script.internal.builtin_impls import BUILTIN_IMPLS, _bool, _float, _int, _len, _super
16
16
  from sonolus.script.internal.constant import ConstantValue
17
- from sonolus.script.internal.context import Context, EmptyBinding, Scope, ValueBinding, ctx, set_ctx, using_ctx
17
+ from sonolus.script.internal.context import (
18
+ ConflictBinding,
19
+ Context,
20
+ EmptyBinding,
21
+ Scope,
22
+ ValueBinding,
23
+ ctx,
24
+ set_ctx,
25
+ using_ctx,
26
+ )
18
27
  from sonolus.script.internal.descriptor import SonolusDescriptor
19
28
  from sonolus.script.internal.error import CompilationError
20
29
  from sonolus.script.internal.impl import meta_fn, validate_value
@@ -22,7 +31,7 @@ from sonolus.script.internal.transient import TransientValue
22
31
  from sonolus.script.internal.tuple_impl import TupleImpl
23
32
  from sonolus.script.internal.value import Value
24
33
  from sonolus.script.iterator import SonolusIterator
25
- from sonolus.script.maybe import Maybe
34
+ from sonolus.script.maybe import Maybe, Nothing
26
35
  from sonolus.script.num import Num, _is_num
27
36
  from sonolus.script.record import Record
28
37
 
@@ -312,13 +321,16 @@ class Visitor(ast.NodeVisitor):
312
321
  yield_merge_ctx = Context.meet(yield_between_ctxs)
313
322
  else:
314
323
  yield_merge_ctx = before_ctx.new_empty_disconnected()
315
- # Making it default to a number for convenience when used with stuff like min, etc.
316
- yield_merge_ctx.scope.set_value("$yield", validate_value(0))
317
324
  yield_binding = yield_merge_ctx.scope.get_binding("$yield")
318
- if not isinstance(yield_binding, ValueBinding):
319
- raise ValueError("Function has conflicting yield values")
320
- with using_ctx(yield_merge_ctx):
321
- is_present_var._set_(1)
325
+ match yield_binding:
326
+ case ValueBinding():
327
+ with using_ctx(yield_merge_ctx):
328
+ is_present_var._set_(1)
329
+ yield_value = Maybe(present=is_present_var, value=yield_binding.value)
330
+ case EmptyBinding():
331
+ yield_value = Nothing
332
+ case ConflictBinding():
333
+ raise ValueError("Function has conflicting yield values")
322
334
  next_result_ctx = Context.meet([yield_merge_ctx, return_ctx])
323
335
  set_ctx(before_ctx)
324
336
  return_test = Num._alloc_()
@@ -327,7 +339,7 @@ class Visitor(ast.NodeVisitor):
327
339
  return_test,
328
340
  entry,
329
341
  next_result_ctx,
330
- Maybe(present=is_present_var, value=yield_binding.value),
342
+ yield_value,
331
343
  self.used_parent_binding_values,
332
344
  self,
333
345
  )
@@ -739,11 +751,17 @@ class Visitor(ast.NodeVisitor):
739
751
  test = validate_value(subject._is_py_() and subject._as_py_() is None)
740
752
  case _:
741
753
  raise NotImplementedError("Unsupported match singleton")
742
- ctx_init = ctx()
743
- ctx_init.test = test.ir()
744
- true_ctx = ctx_init.branch(None)
745
- false_ctx = ctx_init.branch(0)
746
- return true_ctx, false_ctx
754
+ if test._is_py_():
755
+ if test._as_py_():
756
+ return ctx(), ctx().into_dead()
757
+ else:
758
+ return ctx().into_dead(), ctx()
759
+ else:
760
+ ctx_init = ctx()
761
+ ctx_init.test = test.ir()
762
+ true_ctx = ctx_init.branch(None)
763
+ false_ctx = ctx_init.branch(0)
764
+ return true_ctx, false_ctx
747
765
  case ast.MatchSequence(patterns=patterns):
748
766
  target_len = len(patterns)
749
767
  if not (isinstance(subject, Sequence | TupleImpl)):
@@ -4,6 +4,7 @@ import gzip
4
4
  import hashlib
5
5
  import json
6
6
  import urllib.request
7
+ import warnings
7
8
  import zipfile
8
9
  from io import BytesIO
9
10
  from os import PathLike
@@ -116,6 +117,7 @@ class Collection:
116
117
  try:
117
118
  item_data = json.loads(item_json_path.read_text(encoding="utf-8"))
118
119
  except json.JSONDecodeError:
120
+ warnings.warn(f"Invalid JSON in {item_json_path}, skipping item.", stacklevel=2)
119
121
  continue
120
122
 
121
123
  item_data = self._localize_item(item_data)
@@ -129,7 +131,7 @@ class Collection:
129
131
  resource_data = resource_path.read_bytes()
130
132
 
131
133
  if resource_path.suffix.lower() in {".json", ".bin"}:
132
- resource_data = gzip.compress(resource_data)
134
+ resource_data = gzip.compress(resource_data, mtime=0)
133
135
 
134
136
  srl = self.add_asset(resource_data)
135
137
  item_data[resource_path.stem] = srl
sonolus/build/compile.py CHANGED
@@ -77,7 +77,13 @@ def compile_mode(
77
77
  base_archetype_entries = {}
78
78
 
79
79
  if archetypes is not None:
80
- base_archetypes = {getattr(a, "_derived_base_", a) for a in archetypes}
80
+ base_archetypes = []
81
+ seen_base_archetypes = set()
82
+ for a in archetypes:
83
+ base = getattr(a, "_derived_base_", a)
84
+ if base not in seen_base_archetypes:
85
+ seen_base_archetypes.add(base)
86
+ base_archetypes.append(base)
81
87
 
82
88
  for archetype in base_archetypes:
83
89
  archetype._init_fields()
sonolus/build/engine.py CHANGED
@@ -407,12 +407,12 @@ def package_rom(rom: ReadOnlyMemory) -> bytes:
407
407
  for value in values:
408
408
  output.extend(struct.pack("<f", value))
409
409
 
410
- return gzip.compress(bytes(output))
410
+ return gzip.compress(bytes(output), mtime=0)
411
411
 
412
412
 
413
413
  def package_data(value: JsonValue) -> bytes:
414
414
  json_data = json.dumps(value, separators=(",", ":")).encode("utf-8")
415
- return gzip.compress(json_data)
415
+ return gzip.compress(json_data, mtime=0)
416
416
 
417
417
 
418
418
  def unpackage_data(data: bytes) -> JsonValue:
sonolus/build/project.py CHANGED
@@ -39,7 +39,7 @@ def build_project_to_collection(project: Project, config: BuildConfig | None):
39
39
 
40
40
 
41
41
  def apply_converter_to_collection(
42
- self, src_engine: str | None, converter: Callable[[ExternalLevelData], LevelData]
42
+ self, src_engine: str | None, converter: Callable[[ExternalLevelData], LevelData | None]
43
43
  ) -> None:
44
44
  for level_details in self.categories.get("levels", {}).values():
45
45
  level = level_details["item"]
@@ -56,6 +56,8 @@ def apply_converter_to_collection(
56
56
  raise ValueError(f"Level data for level '{level['name']}' is not valid")
57
57
  parsed_data = parse_external_level_data(cast(ExternalLevelDataDict, data))
58
58
  new_data = converter(parsed_data)
59
+ if new_data is None:
60
+ continue
59
61
  packaged_new_data = package_level_data(new_data)
60
62
  new_data_srl = self.add_asset(packaged_new_data)
61
63
  level["data"] = new_data_srl
@@ -1233,6 +1233,11 @@ class EntityRef[A: _BaseArchetype](Record):
1233
1233
  return self._ref_ is other._ref_
1234
1234
  return super().__eq__(other)
1235
1235
 
1236
+ def __hash__(self) -> int:
1237
+ if not ctx() and hasattr(self, "_ref_"):
1238
+ return hash(id(self._ref_))
1239
+ return super().__hash__()
1240
+
1236
1241
  @meta_fn
1237
1242
  def get(self) -> A:
1238
1243
  """Get the entity."""
sonolus/script/debug.py CHANGED
@@ -80,6 +80,20 @@ def assert_false(value: int | float | bool, message: str | None = None):
80
80
  error(message)
81
81
 
82
82
 
83
+ def static_assert(value: int | float | bool, message: str | None = None):
84
+ message = message if message is not None else "Static assertion failed"
85
+ if not _is_static_true(value):
86
+ static_error(message)
87
+
88
+
89
+ def try_static_assert(value: int | float | bool, message: str | None = None):
90
+ message = message if message is not None else "Static assertion failed"
91
+ if _is_static_false(value):
92
+ static_error(message)
93
+ if not value:
94
+ error(message)
95
+
96
+
83
97
  @meta_fn
84
98
  def assert_unreachable(message: str | None = None) -> Never:
85
99
  # This works a bit differently from assert_never from typing in that it throws an error if the Sonolus.py
@@ -140,3 +154,21 @@ def visualize_cfg(
140
154
 
141
155
  def simulation_context() -> SimulationContext:
142
156
  return SimulationContext()
157
+
158
+
159
+ @meta_fn
160
+ def _is_static_true(value: int | float | bool) -> bool:
161
+ if ctx() is None:
162
+ return bool(value)
163
+ else:
164
+ value = validate_value(value)
165
+ return value._is_py_() and value._as_py_()
166
+
167
+
168
+ @meta_fn
169
+ def _is_static_false(value: int | float | bool) -> bool:
170
+ if ctx() is None:
171
+ return not bool(value)
172
+ else:
173
+ value = validate_value(value)
174
+ return value._is_py_() and not value._as_py_()
sonolus/script/effect.py CHANGED
@@ -1,9 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Iterable
3
4
  from dataclasses import dataclass
4
5
  from typing import Annotated, Any, NewType, dataclass_transform, get_origin
5
6
 
6
7
  from sonolus.backend.ops import Op
8
+ from sonolus.script.array_like import ArrayLike
9
+ from sonolus.script.debug import static_error
7
10
  from sonolus.script.internal.introspection import get_field_specifiers
8
11
  from sonolus.script.internal.native import native_function
9
12
  from sonolus.script.record import Record
@@ -92,6 +95,29 @@ class ScheduledLoopedEffectHandle(Record):
92
95
  _stop_looped_scheduled(self.id, end_time)
93
96
 
94
97
 
98
+ class EffectGroup(Record, ArrayLike[Effect]):
99
+ """A group of effect clips.
100
+
101
+ Usage:
102
+ ```python
103
+ EffectGroup(start_id: int, size: int)
104
+ ```
105
+ """
106
+
107
+ start_id: int
108
+ size: int
109
+
110
+ def __len__(self) -> int:
111
+ return self.size
112
+
113
+ def __getitem__(self, index: int) -> Effect:
114
+ assert 0 <= index < self.size
115
+ return Effect(self.start_id + index)
116
+
117
+ def __setitem__(self, index: int, value: Effect) -> None:
118
+ static_error("EffectGroup is read-only")
119
+
120
+
95
121
  @native_function(Op.HasEffectClip)
96
122
  def _has_effect_clip(effect_id: int) -> bool:
97
123
  raise NotImplementedError
@@ -132,11 +158,21 @@ class EffectInfo:
132
158
  name: str
133
159
 
134
160
 
161
+ @dataclass
162
+ class EffectGroupInfo:
163
+ names: list[str]
164
+
165
+
135
166
  def effect(name: str) -> Any:
136
167
  """Define a sound effect clip with the given name."""
137
168
  return EffectInfo(name)
138
169
 
139
170
 
171
+ def effect_group(names: Iterable[str]) -> Any:
172
+ """Define an effect group with the given names."""
173
+ return EffectGroupInfo(list(names))
174
+
175
+
140
176
  type Effects = NewType("Effects", Any) # type: ignore
141
177
 
142
178
 
@@ -150,24 +186,45 @@ def effects[T](cls: type[T]) -> T | Effects:
150
186
  class Effects:
151
187
  miss: StandardEffect.MISS
152
188
  other: Effect = effect("other")
189
+ group_1: EffectGroup = effect_group(["one", "two", "three"])
190
+ group_2: EffectGroup = effect_group(f"name_{i}" for i in range(10))
153
191
  ```
154
192
  """
155
193
  if len(cls.__bases__) != 1:
156
194
  raise ValueError("Effects class must not inherit from any class (except object)")
157
195
  instance = cls()
158
196
  names = []
159
- for i, (name, annotation) in enumerate(get_field_specifiers(cls).items()):
197
+ i = 0
198
+ for name, annotation in get_field_specifiers(cls).items():
160
199
  if get_origin(annotation) is not Annotated:
161
- raise TypeError(f"Invalid annotation for effects: {annotation}")
200
+ raise TypeError(f"Invalid annotation for effects: {annotation} on field {name}")
162
201
  annotation_type = annotation.__args__[0]
163
202
  annotation_values = annotation.__metadata__
164
- if annotation_type is not Effect:
165
- raise TypeError(f"Invalid annotation for effects: {annotation}, expected annotation of type Effect")
166
- if len(annotation_values) != 1 or not isinstance(annotation_values[0], EffectInfo):
167
- raise TypeError(f"Invalid annotation for effects: {annotation}, expected a single string annotation value")
168
- effect_name = annotation_values[0].name
169
- names.append(effect_name)
170
- setattr(instance, name, Effect(i))
203
+ if len(annotation_values) != 1:
204
+ raise TypeError(f"Invalid annotation for effects: {annotation} on field {name}, too many annotation values")
205
+ effect_info = annotation_values[0]
206
+ match effect_info:
207
+ case EffectInfo(name=effect_name):
208
+ if annotation_type is not Effect:
209
+ raise TypeError(f"Invalid annotation for effects: {annotation} on field {name}, expected Effect")
210
+ names.append(effect_name)
211
+ setattr(instance, name, Effect(i))
212
+ i += 1
213
+ case EffectGroupInfo(names=effect_names):
214
+ if annotation_type is not EffectGroup:
215
+ raise TypeError(
216
+ f"Invalid annotation for effects: {annotation} on field {name}, expected EffectGroup"
217
+ )
218
+ start_id = i
219
+ count = len(effect_names)
220
+ names.extend(effect_names)
221
+ setattr(instance, name, EffectGroup(start_id, count))
222
+ i += count
223
+ case _:
224
+ raise TypeError(
225
+ f"Invalid annotation for effects: {annotation} on field {name}, unknown effect info, "
226
+ f"expected an effect() or effect_group() specifier"
227
+ )
171
228
  instance._effects_ = names
172
229
  instance._is_comptime_value_ = True
173
230
  return instance
sonolus/script/level.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import gzip
3
4
  import json
4
5
  from collections.abc import Iterator
5
6
  from os import PathLike
@@ -229,7 +230,21 @@ class ExternalEntityData(NamedTuple):
229
230
  data: dict[str, Any]
230
231
 
231
232
 
232
- def parse_external_level_data(raw_data: ExternalLevelDataDict) -> ExternalLevelData:
233
+ def parse_external_level_data(raw_data: ExternalLevelDataDict | str | bytes, /) -> ExternalLevelData:
234
+ """Parse level data from an external source.
235
+
236
+ If given a string, it is parsed as JSON. If given bytes, it is un-gzipped and then parsed as JSON.
237
+
238
+ Args:
239
+ raw_data: The raw level data to parse.
240
+
241
+ Returns:
242
+ The parsed level data.
243
+ """
244
+ if isinstance(raw_data, bytes):
245
+ raw_data = gzip.decompress(raw_data).decode("utf-8")
246
+ if isinstance(raw_data, str):
247
+ raw_data = json.loads(raw_data)
233
248
  bgm_offset = raw_data["bgmOffset"]
234
249
  raw_entities = raw_data["entities"]
235
250
  entity_name_to_index = {e["name"]: i for i, e in enumerate(raw_entities) if "name" in e}
sonolus/script/num.py CHANGED
@@ -131,6 +131,8 @@ class _Num(Value, metaclass=_NumMeta):
131
131
  if isinstance(self.data.block, BlockData) and not ctx().is_writable(self.data):
132
132
  # This block is immutable in the current callback, so no need to copy it in case it changes.
133
133
  return Num(self.data)
134
+ if isinstance(self.data, int | float | bool):
135
+ return Num(self.data)
134
136
  place = ctx().alloc(size=1)
135
137
  ctx().add_statements(IRSet(place, self.ir()))
136
138
  return Num(place)
@@ -1,9 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Iterable
3
4
  from dataclasses import dataclass
4
5
  from typing import Annotated, Any, NewType, dataclass_transform, get_origin
5
6
 
6
7
  from sonolus.backend.ops import Op
8
+ from sonolus.script.array_like import ArrayLike
9
+ from sonolus.script.debug import static_error
7
10
  from sonolus.script.internal.introspection import get_field_specifiers
8
11
  from sonolus.script.internal.native import native_function
9
12
  from sonolus.script.quad import QuadLike, flatten_quad
@@ -52,6 +55,29 @@ class ParticleHandle(Record):
52
55
  _destroy_particle_effect(self.id)
53
56
 
54
57
 
58
+ class ParticleGroup(Record, ArrayLike[Particle]):
59
+ """A group of particle effects.
60
+
61
+ Usage:
62
+ ```python
63
+ ParticleGroup(start_id: int, size: int)
64
+ ```
65
+ """
66
+
67
+ start_id: int
68
+ size: int
69
+
70
+ def __len__(self) -> int:
71
+ return self.size
72
+
73
+ def __getitem__(self, index: int) -> Particle:
74
+ assert 0 <= index < self.size
75
+ return Particle(self.start_id + index)
76
+
77
+ def __setitem__(self, index: int, value: Particle) -> None:
78
+ static_error("ParticleGroup is read-only")
79
+
80
+
55
81
  @native_function(Op.HasParticleEffect)
56
82
  def _has_particle_effect(particle_id: int) -> bool:
57
83
  raise NotImplementedError
@@ -91,11 +117,21 @@ class _ParticleInfo:
91
117
  name: str
92
118
 
93
119
 
120
+ @dataclass
121
+ class _ParticleGroupInfo:
122
+ names: list[str]
123
+
124
+
94
125
  def particle(name: str) -> Any:
95
126
  """Define a particle with the given name."""
96
127
  return _ParticleInfo(name)
97
128
 
98
129
 
130
+ def particle_group(names: Iterable[str]) -> Any:
131
+ """Define a particle group with the given names."""
132
+ return _ParticleGroupInfo(list(names))
133
+
134
+
99
135
  type Particles = NewType("Particles", Any) # type: ignore
100
136
 
101
137
 
@@ -109,26 +145,49 @@ def particles[T](cls: type[T]) -> T | Particles:
109
145
  class Particles:
110
146
  tap: StandardParticle.NOTE_CIRCULAR_TAP_RED
111
147
  other: Particle = particle("other")
148
+ group_1: ParticleGroup = particle_group(["one", "two", "three"])
149
+ group_2: ParticleGroup = particle_group(f"name_{i}" for i in range(10))
112
150
  ```
113
151
  """
114
152
  if len(cls.__bases__) != 1:
115
153
  raise ValueError("Particles class must not inherit from any class (except object)")
116
154
  instance = cls()
117
155
  names = []
118
- for i, (name, annotation) in enumerate(get_field_specifiers(cls).items()):
156
+ i = 0
157
+ for name, annotation in get_field_specifiers(cls).items():
119
158
  if get_origin(annotation) is not Annotated:
120
- raise TypeError(f"Invalid annotation for particles: {annotation}")
159
+ raise TypeError(f"Invalid annotation for particles: {annotation} on field {name}")
121
160
  annotation_type = annotation.__args__[0]
122
161
  annotation_values = annotation.__metadata__
123
- if annotation_type is not Particle:
124
- raise TypeError(f"Invalid annotation for particles: {annotation}, expected annotation of type Particle")
125
- if len(annotation_values) != 1 or not isinstance(annotation_values[0], _ParticleInfo):
162
+ if len(annotation_values) != 1:
126
163
  raise TypeError(
127
- f"Invalid annotation for particles: {annotation}, expected a single string annotation value"
164
+ f"Invalid annotation for particles: {annotation} on field {name}, too many annotation values"
128
165
  )
129
- particle_name = annotation_values[0].name
130
- names.append(particle_name)
131
- setattr(instance, name, Particle(i))
166
+ particle_info = annotation_values[0]
167
+ match particle_info:
168
+ case _ParticleInfo(name=particle_name):
169
+ if annotation_type is not Particle:
170
+ raise TypeError(
171
+ f"Invalid annotation for particles: {annotation} on field {name}, expected Particle"
172
+ )
173
+ names.append(particle_name)
174
+ setattr(instance, name, Particle(i))
175
+ i += 1
176
+ case _ParticleGroupInfo(names=particle_names):
177
+ if annotation_type is not ParticleGroup:
178
+ raise TypeError(
179
+ f"Invalid annotation for particles: {annotation} on field {name}, expected ParticleGroup"
180
+ )
181
+ start_id = i
182
+ count = len(particle_names)
183
+ names.extend(particle_names)
184
+ setattr(instance, name, ParticleGroup(start_id, count))
185
+ i += count
186
+ case _:
187
+ raise TypeError(
188
+ f"Invalid annotation for particles: {annotation} on field {name}, unknown particle info, "
189
+ f"expected a particle() or particle_group() specifier"
190
+ )
132
191
  instance._particles_ = names
133
192
  instance._is_comptime_value_ = True
134
193
  return instance
sonolus/script/project.py CHANGED
@@ -28,7 +28,7 @@ class Project:
28
28
  engine: Engine,
29
29
  levels: Iterable[Level] | Callable[[], Iterable[Level]] | None = None,
30
30
  resources: PathLike | None = None,
31
- converters: dict[str | None, Callable[[ExternalLevelData], LevelData]] | None = None,
31
+ converters: dict[str | None, Callable[[ExternalLevelData], LevelData | None]] | None = None,
32
32
  ):
33
33
  self.engine = engine
34
34
  match levels:
sonolus/script/quad.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Protocol
3
+ from typing import Protocol, Self, assert_never, overload
4
4
 
5
5
  from sonolus.script.record import Record
6
6
  from sonolus.script.values import zeros
@@ -57,6 +57,26 @@ class Quad(Record):
57
57
  """The center of the quad."""
58
58
  return (self.bl + self.tr + self.tl + self.br) / 4
59
59
 
60
+ @property
61
+ def mt(self) -> Vec2:
62
+ """The midpoint of the top edge of the quad."""
63
+ return (self.tl + self.tr) / 2
64
+
65
+ @property
66
+ def mr(self) -> Vec2:
67
+ """The midpoint of the right edge of the quad."""
68
+ return (self.tr + self.br) / 2
69
+
70
+ @property
71
+ def mb(self) -> Vec2:
72
+ """The midpoint of the bottom edge of the quad."""
73
+ return (self.bl + self.br) / 2
74
+
75
+ @property
76
+ def ml(self) -> Vec2:
77
+ """The midpoint of the left edge of the quad."""
78
+ return (self.bl + self.tl) / 2
79
+
60
80
  def translate(self, translation: Vec2, /) -> Quad:
61
81
  """Translate the quad by the given translation and return a new quad."""
62
82
  return Quad(
@@ -212,7 +232,7 @@ class Rect(Record):
212
232
  b: float
213
233
  """The bottom edge of the rectangle."""
214
234
 
215
- l: float # noqa: E741
235
+ l: float
216
236
  """The left edge of the rectangle."""
217
237
 
218
238
  @classmethod
@@ -225,6 +245,57 @@ class Rect(Record):
225
245
  l=center.x - dimensions.x / 2,
226
246
  )
227
247
 
248
+ @overload
249
+ @classmethod
250
+ def from_margin(cls, trbl: float, /) -> Rect: ...
251
+
252
+ @overload
253
+ @classmethod
254
+ def from_margin(cls, tb: float, lr: float, /) -> Rect: ...
255
+
256
+ @overload
257
+ @classmethod
258
+ def from_margin(cls, t: float, lr: float, b: float, /) -> Rect: ...
259
+
260
+ @overload
261
+ @classmethod
262
+ def from_margin(cls, t: float, r: float, b: float, l: float, /) -> Rect: ...
263
+
264
+ @classmethod
265
+ def from_margin(cls, a: float, b: float | None = None, c: float | None = None, d: float | None = None, /) -> Self:
266
+ """Create a rectangle based on margins (edge distances) from the origin.
267
+
268
+ Compared to the regular [`Rect`][sonolus.script.quad.Rect] constructor, this method negates the bottom and
269
+ left values, and supports shorthands when fewer than four arguments are provided.
270
+
271
+ The following signatures are supported:
272
+
273
+ - `from_margin(trbl)`: All margins set to `trbl`.
274
+ - `from_margin(tb, lr)`: Top and bottom margins set to `tb`, left and right margins set to `lr`.
275
+ - `from_margin(t, lr, b)`: Top margin set to `t`, left and right margins set to `lr`, bottom margin set to `b`.
276
+ - `from_margin(t, r, b, l)`: Top, right, bottom, and left margins set to `t`, `r`, `b`, and `l` respectively.
277
+
278
+ Usage:
279
+ ```python
280
+ Rect.from_margin(1) # Rect(t=1, r=1, b=-1, l=-1)
281
+ Rect.from_margin(1, 2) # Rect(t=1, r=2, b=-1, l=-2)
282
+ Rect.from_margin(1, 2, 3) # Rect(t=1, r=2, b=-3, l=-2)
283
+ Rect.from_margin(1, 2, 3, 4) # Rect(t=1, r=2, b=-3, l=-4)
284
+ ```
285
+ """
286
+ args = (a, b, c, d)
287
+ match args:
288
+ case (a, None, None, None):
289
+ return cls(t=a, r=a, b=-a, l=-a)
290
+ case (a, b, None, None):
291
+ return cls(t=a, r=b, b=-a, l=-b)
292
+ case (a, b, c, None):
293
+ return cls(t=a, r=b, b=-c, l=-b)
294
+ case (a, b, c, d):
295
+ return cls(t=a, r=b, b=-c, l=-d)
296
+ case _:
297
+ assert_never(args)
298
+
228
299
  @property
229
300
  def w(self) -> float:
230
301
  """The width of the rectangle."""
@@ -260,6 +331,26 @@ class Rect(Record):
260
331
  """The center of the rectangle."""
261
332
  return Vec2((self.l + self.r) / 2, (self.t + self.b) / 2)
262
333
 
334
+ @property
335
+ def mt(self) -> Vec2:
336
+ """The middle-top point of the rectangle."""
337
+ return Vec2((self.l + self.r) / 2, self.t)
338
+
339
+ @property
340
+ def mr(self) -> Vec2:
341
+ """The middle-right point of the rectangle."""
342
+ return Vec2(self.r, (self.t + self.b) / 2)
343
+
344
+ @property
345
+ def mb(self) -> Vec2:
346
+ """The middle-bottom point of the rectangle."""
347
+ return Vec2((self.l + self.r) / 2, self.b)
348
+
349
+ @property
350
+ def ml(self) -> Vec2:
351
+ """The middle-left point of the rectangle."""
352
+ return Vec2(self.l, (self.t + self.b) / 2)
353
+
263
354
  def as_quad(self) -> Quad:
264
355
  """Convert the rectangle to a [`Quad`][sonolus.script.quad.Quad]."""
265
356
  return Quad(
sonolus/script/runtime.py CHANGED
@@ -992,7 +992,7 @@ def time() -> float:
992
992
  def offset_adjusted_time() -> float:
993
993
  """Get the current time of the game adjusted by the input offset.
994
994
 
995
- Returns 0 in preview mode and tutorial mode.
995
+ Returns 0 in preview mode and the current time without adjustment in tutorial mode.
996
996
  """
997
997
  if not ctx():
998
998
  return 0
sonolus/script/sprite.py CHANGED
@@ -1,8 +1,11 @@
1
+ from collections.abc import Iterable
1
2
  from dataclasses import dataclass
2
3
  from enum import StrEnum
3
4
  from typing import Annotated, Any, NewType, dataclass_transform, get_origin
4
5
 
5
6
  from sonolus.backend.ops import Op
7
+ from sonolus.script.array_like import ArrayLike
8
+ from sonolus.script.debug import static_error
6
9
  from sonolus.script.internal.introspection import get_field_specifiers
7
10
  from sonolus.script.internal.native import native_function
8
11
  from sonolus.script.quad import QuadLike, flatten_quad
@@ -111,6 +114,29 @@ class Sprite(Record):
111
114
  _draw_curved_lr(self.id, *flatten_quad(quad), z, a, n, *cp1.tuple, *cp2.tuple)
112
115
 
113
116
 
117
+ class SpriteGroup(Record, ArrayLike[Sprite]):
118
+ """A group of sprites.
119
+
120
+ Usage:
121
+ ```python
122
+ SpriteGroup(start_id: int, size: int)
123
+ ```
124
+ """
125
+
126
+ start_id: int
127
+ size: int
128
+
129
+ def __len__(self) -> int:
130
+ return self.size
131
+
132
+ def __getitem__(self, index: int) -> Sprite:
133
+ assert 0 <= index < self.size
134
+ return Sprite(self.start_id + index)
135
+
136
+ def __setitem__(self, index: int, value: Sprite) -> None:
137
+ static_error("SpriteGroup is read-only")
138
+
139
+
114
140
  @native_function(Op.HasSkinSprite)
115
141
  def _has_skin_sprite(sprite_id: int) -> bool:
116
142
  raise NotImplementedError
@@ -262,11 +288,21 @@ class SkinSprite:
262
288
  name: str
263
289
 
264
290
 
291
+ @dataclass
292
+ class SkinSpriteGroup:
293
+ names: list[str]
294
+
295
+
265
296
  def sprite(name: str) -> Any:
266
297
  """Define a sprite with the given name."""
267
298
  return SkinSprite(name)
268
299
 
269
300
 
301
+ def sprite_group(names: Iterable[str]) -> Any:
302
+ """Define a sprite group with the given names."""
303
+ return SkinSpriteGroup(list(names))
304
+
305
+
270
306
  type Skin = NewType("Skin", Any) # type: ignore
271
307
 
272
308
 
@@ -294,25 +330,44 @@ def skin[T](cls: type[T]) -> T | Skin:
294
330
  render_mode: RenderMode = RenderMode.LIGHTWEIGHT
295
331
 
296
332
  note: StandardSprite.NOTE_HEAD_RED
297
- other: Sprite = skin_sprite("other")
333
+ other: Sprite = sprite("other")
334
+ group_1: SpriteGroup = sprite_group(["one", "two", "three"])
335
+ group_2: SpriteGroup = sprite_group(f"name_{i}" for i in range(10))
298
336
  ```
299
337
  """
300
338
  if len(cls.__bases__) != 1:
301
339
  raise ValueError("Skin class must not inherit from any class (except object)")
302
340
  instance = cls()
303
341
  names = []
304
- for i, (name, annotation) in enumerate(get_field_specifiers(cls, skip={"render_mode"}).items()):
342
+ i = 0
343
+ for name, annotation in get_field_specifiers(cls, skip={"render_mode"}).items():
305
344
  if get_origin(annotation) is not Annotated:
306
- raise TypeError(f"Invalid annotation for skin: {annotation}")
345
+ raise TypeError(f"Invalid annotation for skin: {annotation} on field {name}")
307
346
  annotation_type = annotation.__args__[0]
308
347
  annotation_values = annotation.__metadata__
309
- if annotation_type is not Sprite:
310
- raise TypeError(f"Invalid annotation for skin: {annotation}, expected annotation of type Sprite")
311
- if len(annotation_values) != 1 or not isinstance(annotation_values[0], SkinSprite):
312
- raise TypeError(f"Invalid annotation for skin: {annotation}, expected a single string annotation value")
313
- sprite_name = annotation_values[0].name
314
- names.append(sprite_name)
315
- setattr(instance, name, Sprite(i))
348
+ if len(annotation_values) != 1:
349
+ raise TypeError(f"Invalid annotation for skin: {annotation} on field {name}, too many annotation values")
350
+ sprite_info = annotation_values[0]
351
+ match sprite_info:
352
+ case SkinSprite(name=sprite_name):
353
+ if annotation_type is not Sprite:
354
+ raise TypeError(f"Invalid annotation for skin: {annotation} on field {name}, expected Sprite")
355
+ names.append(sprite_name)
356
+ setattr(instance, name, Sprite(i))
357
+ i += 1
358
+ case SkinSpriteGroup(names=sprite_names):
359
+ if annotation_type is not SpriteGroup:
360
+ raise TypeError(f"Invalid annotation for skin: {annotation} on field {name}, expected SpriteGroup")
361
+ start_id = i
362
+ count = len(sprite_names)
363
+ names.extend(sprite_names)
364
+ setattr(instance, name, SpriteGroup(start_id, count))
365
+ i += count
366
+ case _:
367
+ raise TypeError(
368
+ f"Invalid annotation for skin: {annotation} on field {name}, unknown sprite info, "
369
+ f"expected a skin() or sprite_group() specifier"
370
+ )
316
371
  instance._sprites_ = names
317
372
  instance.render_mode = RenderMode(getattr(instance, "render_mode", RenderMode.DEFAULT))
318
373
  instance._is_comptime_value_ = True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sonolus.py
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Sonolus engine development in Python
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -11,7 +11,7 @@ sonolus/backend/node.py,sha256=eEzPP14jzWJp2xrZCAaPlNtokxdoqg0bSM7xQiwx1j8,1254
11
11
  sonolus/backend/ops.py,sha256=5weB_vIxbkwCSJuzYZyKUk7vVXsSIEDJYRlvE-2ke8A,10572
12
12
  sonolus/backend/place.py,sha256=7qwV732hZ4WP-9GNN8FQSEKssPJZELip1wLXTWfop7Y,4717
13
13
  sonolus/backend/utils.py,sha256=OwD1EPh8j-hsfkLzeKNzPQojT_3kklpJou0WTJNoCbc,2337
14
- sonolus/backend/visitor.py,sha256=oq8IhuuhigXMZtNgxD9ZjI_ZLVrp9ikFm9Yz636F5Fo,63102
14
+ sonolus/backend/visitor.py,sha256=SIkrr7JOOQ4is9ov2siBpczpb_vWZZyckBQQr2BojrQ,63437
15
15
  sonolus/backend/optimize/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  sonolus/backend/optimize/allocate.py,sha256=CuumoMphkpQlGRNeKLHT4FBGE0XVj5pwhfNdrqiLFSs,7535
17
17
  sonolus/backend/optimize/constant_evaluation.py,sha256=U--9moGsXFzrgweWWwHIiEuuMzwetd1IOjjtrCscoNM,21450
@@ -27,39 +27,39 @@ sonolus/backend/optimize/simplify.py,sha256=RDNVTKfC7ByRyxY5z30_ShimOAKth_pKlVFV
27
27
  sonolus/backend/optimize/ssa.py,sha256=raQO0furQQRPYb8iIBKfNrJlj-_5wqtI4EWNfLZ8QFo,10834
28
28
  sonolus/build/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
29
29
  sonolus/build/cli.py,sha256=-_lTN8zT7nQB2lySM8itAEPVutcEQI-TJ13BcPIfGb4,10113
30
- sonolus/build/collection.py,sha256=sMYLfEIVn6UydLy7iEnNwrpIXDs7bGG32uQQgHXyhSI,12289
31
- sonolus/build/compile.py,sha256=Yex-ZbSZmv9ujyAOVaMcfq-0NnZzFIqtmNYYaplF4Uc,6761
32
- sonolus/build/engine.py,sha256=xPhNnaFkfAuflp7CYb2NrXTgh9xCNN2h_hT_uWAYl1g,13445
30
+ sonolus/build/collection.py,sha256=KgMfdLsCA7bheT-E2wmdB2OBEWolbsECm8vrwAhGC1c,12415
31
+ sonolus/build/compile.py,sha256=mrbKTlhgu1x0YWijd1pIGHcOg0UTHPx4rEECPZuCRSM,6968
32
+ sonolus/build/engine.py,sha256=YQ2Sc2zC56N2Y5S5zE3QCoUzwKcVHSPl14iQE0tkm6Q,13463
33
33
  sonolus/build/level.py,sha256=KLqUAtxIuIqrzeFURJA97rdqjA5pcvYSmwNZQhElaMQ,702
34
34
  sonolus/build/node.py,sha256=gnX71RYDUOK_gYMpinQi-bLWO4csqcfiG5gFmhxzSec,1330
35
- sonolus/build/project.py,sha256=h-9tcC60KqrpUOxGHFCK9trGVhGPUkciXiibasBEzu4,8097
35
+ sonolus/build/project.py,sha256=__sOJas3RTwn4aizq16rYCvZgG8aX-JbQaxmfVhRnc4,8154
36
36
  sonolus/script/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
- sonolus/script/archetype.py,sha256=xoBVt4sQcC0e1ikyaM4NfcDZrMrJ0lHHUrxznl6dsU4,49492
37
+ sonolus/script/archetype.py,sha256=xhdm1jO_p32V1NtGjgV0SGPm7X9GnJ6Z7hXi2zT69Ww,49647
38
38
  sonolus/script/array.py,sha256=9uOUHZIDMyMT9q3APcXJWXWt97yG-AZoRlxwrvSY6SU,12367
39
39
  sonolus/script/array_like.py,sha256=hUDdDaP306kflVv9YomdHIMXogrVjxsBXCrLvB9QpuE,9681
40
40
  sonolus/script/bucket.py,sha256=SNqnfLGpnO_u-3LFFazdgzNk332OdDUIlmZHsuoVZuE,7674
41
41
  sonolus/script/containers.py,sha256=L3rzPfN1cDb2m02k9qRyjnGxJx07X3q3MccCaL5tNJ8,18773
42
- sonolus/script/debug.py,sha256=Vi97opuk9fMB5bdpzPCBpa2oZeTff7FQ4WzdeDE5trk,4557
42
+ sonolus/script/debug.py,sha256=KNc2zhzLzQr_YZV9BkZCX4GP00Sev2O23jb6PDpHC4w,5472
43
43
  sonolus/script/easing.py,sha256=2FUJI_nfp990P_armCcRqHm2329O985glJAhSC6tnxs,11379
44
- sonolus/script/effect.py,sha256=RQdsABqfi3sIku1jJDwyeD81devVyBhWQ47HIjfiMy0,5978
44
+ sonolus/script/effect.py,sha256=pYihzdVOS3ekiiTwPVg6czUW1UNv0CJNIk-xBsYRdq8,7792
45
45
  sonolus/script/engine.py,sha256=etI9dJsQ7V9YZICVNZg54WqpLijPxG8eTPHiV-_EiG8,10687
46
46
  sonolus/script/globals.py,sha256=nlXSNS4NRXsgQU2AJImVIs752h1WqsMnShSKgU011c4,10270
47
47
  sonolus/script/instruction.py,sha256=Dd-14D5Amo8nhPBr6DNyg2lpYw_rqZkT8Kix3HkfE7k,6793
48
48
  sonolus/script/interval.py,sha256=dj6F2wn5uP6I6_mcZn-wIREgRUQbsLzhvhzB0oEyAdU,11290
49
49
  sonolus/script/iterator.py,sha256=_ICY_yX7FG0Zbgs3NhVnaIBdVDpAeXjxJ_CQtq30l7Y,3774
50
- sonolus/script/level.py,sha256=5M_XTzvXpFZTY-ODlS6M3GOiCIYN3F8rFuUgOiGxVBI,7824
50
+ sonolus/script/level.py,sha256=X3-V99ihruYYCcPdch66dHi_ydCWXXn7epviLLjxW8w,8288
51
51
  sonolus/script/maybe.py,sha256=VYvTWgEfPzoXqI3i3zXhc4dz0pWBVoHmW8FtWH0GQvM,8194
52
52
  sonolus/script/metadata.py,sha256=ttRK27eojHf3So50KQJ-8yj3udZoN1bli5iD-knaeLw,753
53
- sonolus/script/num.py,sha256=924kWWZusW7oaWuvtQzdAMzkb4ZItWSJwNj3W9XrqZU,16041
53
+ sonolus/script/num.py,sha256=s3Sl4nxZAjkOg-UwzJiWkUsV0lyJQPTOyjhAfzWFTf8,16137
54
54
  sonolus/script/options.py,sha256=XVN-mL7Rwhd2Tu9YysYq9YDGpH_LazdmhqzSYE6nR3Q,9455
55
- sonolus/script/particle.py,sha256=RTfamg_ZTq7-qJ6j9paduNVUHCkw3hzkBK1QUbwwN7I,8403
55
+ sonolus/script/particle.py,sha256=rCEJGT7frqJqZLi4EBCqDs4QBvLW6Ys640nD1FxiVec,10326
56
56
  sonolus/script/pointer.py,sha256=FoOfyD93r0G5d_2BaKfeOT9SqkOP3hq6sqtOs_Rb0c8,1511
57
57
  sonolus/script/printing.py,sha256=mNYu9QWiacBBGZrnePZQMVwbbguoelUps9GiOK_aVRU,2096
58
- sonolus/script/project.py,sha256=a9bRY5T8deRMR35iC-6cuqn4HiKwZHvVy9K-np-rAfI,4432
59
- sonolus/script/quad.py,sha256=XoAjaUqR60zIrC_CwheZs7HwS-DRS58yUmlj9GIjX7k,11179
58
+ sonolus/script/project.py,sha256=gG6mYsGlIqznY_RODyFMiXVBh0tVbXLAWZ5LHCiBIuU,4439
59
+ sonolus/script/quad.py,sha256=9qUCOVirSShSE_hP9G0ZIkewNX990sw476PNwwJlWdU,14377
60
60
  sonolus/script/record.py,sha256=igmawc0hqb98YVcZnPTThHvnIP88Qelhoge4cNJx45Q,12770
61
- sonolus/script/runtime.py,sha256=rJZM_KbKmnwpjhDEpR0DrM6EMSEu46apIErWA_pfLJA,33321
62
- sonolus/script/sprite.py,sha256=kqFESwQjR-tTJ3EEMnXmMBxX-gCjtPYwF5Pgx1OInNM,16317
61
+ sonolus/script/runtime.py,sha256=VwmW-8fy-uKyfUNewtXQhphmLZP7ugQeMWBFA8zi28w,33360
62
+ sonolus/script/sprite.py,sha256=J4rA558MSNjNkI3KPnDp3R5RyJRmpi5P5cDfg6dKlIU,18065
63
63
  sonolus/script/stream.py,sha256=qVljaCxJJtQs7aBwfUG15pYvztb4jFYzSLGDh5t4DTA,24713
64
64
  sonolus/script/text.py,sha256=wxujIgKYcCfl2AD2_Im8g3vh0lDEHYwTSRZg9wsBPEU,13402
65
65
  sonolus/script/timing.py,sha256=DklMvuxcFg3MzXsecUo6Yhdk7pScOJ7STwXvAiTvLKM,3067
@@ -86,8 +86,8 @@ sonolus/script/internal/simulation_context.py,sha256=LGxLTvxbqBIhoe1R-SfwGajNIDw
86
86
  sonolus/script/internal/transient.py,sha256=y2AWABqF1aoaP6H4_2u4MMpNioC4OsZQCtPyNI0txqo,1634
87
87
  sonolus/script/internal/tuple_impl.py,sha256=DPNdmmRmupU8Ah4_XKq6-PdT336l4nt15_uCJKQGkkk,3587
88
88
  sonolus/script/internal/value.py,sha256=OngrCdmY_h6mV2Zgwqhuo4eYFad0kTk6263UAxctZcY,6963
89
- sonolus_py-0.7.0.dist-info/METADATA,sha256=aTrZv-zipIgHI36FFgSO_bmIjPOAixQa4-CdHcq7Cvc,302
90
- sonolus_py-0.7.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
91
- sonolus_py-0.7.0.dist-info/entry_points.txt,sha256=oTYspY_b7SA8TptEMTDxh4-Aj-ZVPnYC9f1lqH6s9G4,54
92
- sonolus_py-0.7.0.dist-info/licenses/LICENSE,sha256=JEKpqVhQYfEc7zg3Mj462sKbKYmO1K7WmvX1qvg9IJk,1067
93
- sonolus_py-0.7.0.dist-info/RECORD,,
89
+ sonolus_py-0.8.0.dist-info/METADATA,sha256=Q-mtT3V7JhxLurDNDQyLmz5Hre95CGj-T-NHbh0hTMk,302
90
+ sonolus_py-0.8.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
91
+ sonolus_py-0.8.0.dist-info/entry_points.txt,sha256=oTYspY_b7SA8TptEMTDxh4-Aj-ZVPnYC9f1lqH6s9G4,54
92
+ sonolus_py-0.8.0.dist-info/licenses/LICENSE,sha256=JEKpqVhQYfEc7zg3Mj462sKbKYmO1K7WmvX1qvg9IJk,1067
93
+ sonolus_py-0.8.0.dist-info/RECORD,,