sonolus.py 0.1.9__py3-none-any.whl → 0.2.1__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.

Files changed (47) hide show
  1. sonolus/backend/optimize/constant_evaluation.py +2 -2
  2. sonolus/backend/optimize/optimize.py +12 -4
  3. sonolus/backend/optimize/passes.py +2 -1
  4. sonolus/backend/place.py +95 -14
  5. sonolus/backend/visitor.py +51 -3
  6. sonolus/build/cli.py +60 -9
  7. sonolus/build/collection.py +68 -26
  8. sonolus/build/compile.py +85 -27
  9. sonolus/build/engine.py +166 -40
  10. sonolus/build/node.py +8 -1
  11. sonolus/build/project.py +30 -11
  12. sonolus/script/archetype.py +154 -32
  13. sonolus/script/array.py +14 -3
  14. sonolus/script/array_like.py +6 -2
  15. sonolus/script/containers.py +166 -0
  16. sonolus/script/debug.py +22 -4
  17. sonolus/script/effect.py +2 -2
  18. sonolus/script/engine.py +125 -17
  19. sonolus/script/internal/builtin_impls.py +21 -2
  20. sonolus/script/internal/constant.py +8 -4
  21. sonolus/script/internal/context.py +30 -25
  22. sonolus/script/internal/math_impls.py +2 -1
  23. sonolus/script/internal/transient.py +7 -3
  24. sonolus/script/internal/value.py +33 -11
  25. sonolus/script/interval.py +8 -3
  26. sonolus/script/iterator.py +17 -0
  27. sonolus/script/level.py +113 -10
  28. sonolus/script/metadata.py +32 -0
  29. sonolus/script/num.py +35 -15
  30. sonolus/script/options.py +23 -6
  31. sonolus/script/pointer.py +11 -1
  32. sonolus/script/project.py +41 -5
  33. sonolus/script/quad.py +55 -1
  34. sonolus/script/record.py +10 -5
  35. sonolus/script/runtime.py +78 -16
  36. sonolus/script/sprite.py +18 -1
  37. sonolus/script/text.py +9 -0
  38. sonolus/script/ui.py +20 -7
  39. sonolus/script/values.py +8 -5
  40. sonolus/script/vec.py +28 -0
  41. sonolus_py-0.2.1.dist-info/METADATA +10 -0
  42. {sonolus_py-0.1.9.dist-info → sonolus_py-0.2.1.dist-info}/RECORD +46 -45
  43. {sonolus_py-0.1.9.dist-info → sonolus_py-0.2.1.dist-info}/WHEEL +1 -1
  44. sonolus_py-0.1.9.dist-info/METADATA +0 -9
  45. /sonolus/script/{print.py → printing.py} +0 -0
  46. {sonolus_py-0.1.9.dist-info → sonolus_py-0.2.1.dist-info}/entry_points.txt +0 -0
  47. {sonolus_py-0.1.9.dist-info → sonolus_py-0.2.1.dist-info}/licenses/LICENSE +0 -0
sonolus/script/level.py CHANGED
@@ -1,7 +1,44 @@
1
1
  from __future__ import annotations
2
2
 
3
- from sonolus.build.collection import Asset
3
+ import json
4
+ from collections.abc import Iterator
5
+ from os import PathLike
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from sonolus.build.collection import Asset, load_asset
4
10
  from sonolus.script.archetype import PlayArchetype, StandardArchetypeName, StandardImport
11
+ from sonolus.script.metadata import AnyText, Tag, as_localization_text
12
+
13
+
14
+ class ExportedLevel:
15
+ """An exported Sonolus level."""
16
+
17
+ def __init__(
18
+ self,
19
+ *,
20
+ item: dict,
21
+ cover: bytes,
22
+ bgm: bytes,
23
+ preview: bytes | None,
24
+ data: bytes,
25
+ ):
26
+ self.item = item
27
+ self.cover = cover
28
+ self.bgm = bgm
29
+ self.preview = preview
30
+ self.data = data
31
+
32
+ def write_to_dir(self, path: PathLike):
33
+ """Write the exported level to a directory."""
34
+ path = Path(path)
35
+ path.mkdir(parents=True, exist_ok=True)
36
+ (path / "item.json").write_text(json.dumps(self.item, ensure_ascii=False), encoding="utf-8")
37
+ (path / "cover").write_bytes(self.cover)
38
+ (path / "bgm").write_bytes(self.bgm)
39
+ if self.preview is not None:
40
+ (path / "preview").write_bytes(self.preview)
41
+ (path / "data").write_bytes(self.data)
5
42
 
6
43
 
7
44
  class Level:
@@ -16,6 +53,13 @@ class Level:
16
53
  cover: The cover of the level.
17
54
  bgm: The background music of the level.
18
55
  data: The data of the level.
56
+ use_skin: The skin to use, overriding the engine skin.
57
+ use_background: The background to use, overriding the engine background.
58
+ use_effect: The effect to use, overriding the engine effect.
59
+ use_particle: The particle to use, overriding the engine particle.
60
+ tags: The tags of the level.
61
+ description: The description of the level.
62
+ meta: Additional metadata of the level.
19
63
  """
20
64
 
21
65
  version = 1
@@ -24,35 +68,94 @@ class Level:
24
68
  self,
25
69
  *,
26
70
  name: str,
27
- title: str | None = None,
71
+ title: AnyText | None = None,
28
72
  rating: int = 0,
29
- artists: str = "Unknown",
30
- author: str = "Unknown",
73
+ artists: AnyText = "Unknown",
74
+ author: AnyText = "Unknown",
31
75
  cover: Asset | None = None,
32
76
  bgm: Asset | None = None,
77
+ preview: Asset | None = None,
33
78
  data: LevelData,
79
+ use_skin: str | None = None,
80
+ use_background: str | None = None,
81
+ use_effect: str | None = None,
82
+ use_particle: str | None = None,
83
+ tags: list[Tag] | None = None,
84
+ description: AnyText | None = None,
85
+ meta: Any = None,
34
86
  ) -> None:
35
87
  self.name = name
36
- self.title = title or name
88
+ self.title = as_localization_text(title or name)
37
89
  self.rating = rating
38
- self.artists = artists
39
- self.author = author
90
+ self.artists = as_localization_text(artists)
91
+ self.author = as_localization_text(author)
40
92
  self.cover = cover
41
93
  self.bgm = bgm
94
+ self.preview = preview
42
95
  self.data = data
96
+ self.use_skin = use_skin
97
+ self.use_background = use_background
98
+ self.use_effect = use_effect
99
+ self.use_particle = use_particle
100
+ self.tags = tags or []
101
+ self.description = as_localization_text(description) if description is not None else None
102
+ self.meta = meta
103
+
104
+ def export(self, engine_name: str) -> ExportedLevel:
105
+ """Export the level in a sonolus-pack compatible format.
106
+
107
+ Args:
108
+ engine_name: The name of the engine this level is for.
109
+
110
+ Returns:
111
+ The exported level.
112
+ """
113
+ from sonolus.build.level import package_level_data
114
+ from sonolus.build.project import BLANK_AUDIO, BLANK_PNG
115
+
116
+ item = {
117
+ "version": self.version,
118
+ "rating": self.rating,
119
+ "engine": engine_name,
120
+ "useSkin": {"useDefault": True} if self.use_skin is None else {"useDefault": False, "item": self.use_skin},
121
+ "useBackground": {"useDefault": True}
122
+ if self.use_background is None
123
+ else {"useDefault": False, "item": self.use_background},
124
+ "useEffect": {"useDefault": True}
125
+ if self.use_effect is None
126
+ else {"useDefault": False, "item": self.use_effect},
127
+ "useParticle": {"useDefault": True}
128
+ if self.use_particle is None
129
+ else {"useDefault": False, "item": self.use_particle},
130
+ "title": self.title,
131
+ "artists": self.artists,
132
+ "author": self.author,
133
+ "tags": [tag.as_dict() for tag in self.tags],
134
+ }
135
+ if self.description is not None:
136
+ item["description"] = self.description
137
+ if self.meta is not None:
138
+ item["meta"] = self.meta
139
+ return ExportedLevel(
140
+ item=item,
141
+ cover=load_asset(self.cover) if self.cover is not None else BLANK_PNG,
142
+ bgm=load_asset(self.bgm) if self.bgm is not None else BLANK_AUDIO,
143
+ preview=load_asset(self.preview) if self.preview is not None else None,
144
+ data=package_level_data(self.data),
145
+ )
43
146
 
44
147
 
45
148
  type EntityListArg = list[PlayArchetype | EntityListArg]
46
149
 
47
150
 
48
- def flatten_entities(entities: EntityListArg):
151
+ def flatten_entities(entities: EntityListArg) -> Iterator[PlayArchetype]:
49
152
  """Flatten a list of entities.
50
153
 
51
154
  Args:
52
155
  entities: The list of entities.
53
156
 
54
- Returns:
55
- The flattened list of entities.
157
+ Yields:
158
+ The flattened entities.
56
159
  """
57
160
  if isinstance(entities, list):
58
161
  for entity in entities:
@@ -0,0 +1,32 @@
1
+ from typing import Any
2
+
3
+ type LocalizationText = dict[str, str]
4
+ type AnyText = str | LocalizationText
5
+
6
+
7
+ def as_localization_text(text: AnyText) -> LocalizationText:
8
+ if isinstance(text, str):
9
+ return {"en": text}
10
+ return text
11
+
12
+
13
+ class Tag:
14
+ """A tag for an engine or level.
15
+
16
+ Args:
17
+ title: The title of the tag.
18
+ icon: The icon of the tag.
19
+ """
20
+
21
+ title: LocalizationText
22
+ icon: str | None
23
+
24
+ def __init__(self, title: AnyText, icon: str | None = None) -> None:
25
+ self.title = as_localization_text(title)
26
+ self.icon = icon
27
+
28
+ def as_dict(self) -> dict[str, Any]:
29
+ result = {"title": self.title}
30
+ if self.icon is not None:
31
+ result["icon"] = self.icon
32
+ return result
sonolus/script/num.py CHANGED
@@ -7,11 +7,11 @@ from typing import TYPE_CHECKING, Any, Self, TypeGuard, final, runtime_checkable
7
7
 
8
8
  from sonolus.backend.ir import IRConst, IRGet, IRPureInstr, IRSet
9
9
  from sonolus.backend.ops import Op
10
- from sonolus.backend.place import BlockPlace, Place
10
+ from sonolus.backend.place import BlockPlace
11
11
  from sonolus.script.internal.context import ctx
12
12
  from sonolus.script.internal.error import InternalError
13
13
  from sonolus.script.internal.impl import meta_fn
14
- from sonolus.script.internal.value import Value
14
+ from sonolus.script.internal.value import BackingValue, DataValue, Value
15
15
 
16
16
 
17
17
  class _NumMeta(type):
@@ -30,15 +30,17 @@ class _Num(Value, metaclass=_NumMeta):
30
30
  # Since we don't support complex numbers, real is equal to the original number
31
31
  __match_args__ = ("real",)
32
32
 
33
- data: BlockPlace | float | int | bool
33
+ data: DataValue
34
34
 
35
- def __init__(self, data: Place | float | int | bool):
35
+ def __init__(self, data: DataValue):
36
36
  if isinstance(data, complex):
37
37
  raise TypeError("Cannot create a Num from a complex number")
38
38
  if isinstance(data, int):
39
39
  data = float(data)
40
40
  if _is_num(data):
41
41
  raise InternalError("Cannot create a Num from a Num")
42
+ if not isinstance(data, BlockPlace | BackingValue | float | int | bool):
43
+ raise TypeError(f"Cannot create a Num from {type(data)}")
42
44
  self.data = data
43
45
 
44
46
  def __str__(self) -> str:
@@ -78,7 +80,7 @@ class _Num(Value, metaclass=_NumMeta):
78
80
  return cls(value)
79
81
 
80
82
  def _is_py_(self) -> bool:
81
- return not isinstance(self.data, BlockPlace)
83
+ return isinstance(self.data, float | int | bool)
82
84
 
83
85
  def _as_py_(self) -> Any:
84
86
  if not self._is_py_():
@@ -88,11 +90,11 @@ class _Num(Value, metaclass=_NumMeta):
88
90
  return self.data
89
91
 
90
92
  @classmethod
91
- def _from_list_(cls, values: Iterable[float | BlockPlace]) -> Self:
93
+ def _from_list_(cls, values: Iterable[DataValue]) -> Self:
92
94
  value = next(iter(values))
93
95
  return Num(value)
94
96
 
95
- def _to_list_(self, level_refs: dict[Any, str] | None = None) -> list[float | str | BlockPlace]:
97
+ def _to_list_(self, level_refs: dict[Any, str] | None = None) -> list[DataValue]:
96
98
  return [self.data]
97
99
 
98
100
  @classmethod
@@ -111,10 +113,14 @@ class _Num(Value, metaclass=_NumMeta):
111
113
 
112
114
  def _set_(self, value: Self):
113
115
  if ctx():
114
- if not isinstance(self.data, BlockPlace):
115
- raise ValueError("Cannot set a compile time constant value")
116
- ctx().check_writable(self.data)
117
- ctx().add_statements(IRSet(self.data, value.ir()))
116
+ match self.data:
117
+ case BackingValue():
118
+ ctx().add_statements(self.data.write(value))
119
+ case BlockPlace():
120
+ ctx().check_writable(self.data)
121
+ ctx().add_statements(IRSet(self.data, value.ir()))
122
+ case _:
123
+ raise ValueError("Cannot set a read-only value")
118
124
  else:
119
125
  self.data = value.data
120
126
 
@@ -134,13 +140,27 @@ class _Num(Value, metaclass=_NumMeta):
134
140
  else:
135
141
  return Num(-1)
136
142
 
137
- def ir(self):
138
- if isinstance(self.data, BlockPlace):
139
- return IRGet(self.data)
143
+ @classmethod
144
+ def _zero_(cls) -> Self:
145
+ if ctx():
146
+ result_place = ctx().alloc(size=1)
147
+ ctx().add_statements(IRSet(result_place, IRConst(0)))
148
+ return cls(result_place)
140
149
  else:
141
- return IRConst(self.data)
150
+ return cls(0)
151
+
152
+ def ir(self):
153
+ match self.data:
154
+ case BlockPlace():
155
+ return IRGet(self.data)
156
+ case BackingValue():
157
+ return self.data.read()
158
+ case _:
159
+ return IRConst(self.data)
142
160
 
143
161
  def index(self) -> int | BlockPlace:
162
+ if isinstance(self.data, BlockPlace):
163
+ return self._get_().data
144
164
  return self.data
145
165
 
146
166
  def _bin_op(self, other: Self, const_fn: Callable[[Self, Self], Self | None], ir_op: Op) -> Self:
sonolus/script/options.py CHANGED
@@ -15,6 +15,7 @@ from sonolus.script.num import Num
15
15
  @dataclass
16
16
  class _SliderOption:
17
17
  name: str | None
18
+ description: str | None
18
19
  standard: bool
19
20
  advanced: bool
20
21
  scope: str | None
@@ -35,6 +36,8 @@ class _SliderOption:
35
36
  "max": self.max,
36
37
  "step": self.step,
37
38
  }
39
+ if self.description is not None:
40
+ result["description"] = self.description
38
41
  if self.scope is not None:
39
42
  result["scope"] = self.scope
40
43
  if self.unit is not None:
@@ -45,6 +48,7 @@ class _SliderOption:
45
48
  @dataclass
46
49
  class _ToggleOption:
47
50
  name: str | None
51
+ description: str | None
48
52
  standard: bool
49
53
  advanced: bool
50
54
  scope: str | None
@@ -58,6 +62,8 @@ class _ToggleOption:
58
62
  "advanced": self.advanced,
59
63
  "def": int(self.default),
60
64
  }
65
+ if self.description is not None:
66
+ result["description"] = self.description
61
67
  if self.scope is not None:
62
68
  result["scope"] = self.scope
63
69
  return result
@@ -66,10 +72,11 @@ class _ToggleOption:
66
72
  @dataclass
67
73
  class _SelectOption:
68
74
  name: str | None
75
+ description: str | None
69
76
  standard: bool
70
77
  advanced: bool
71
78
  scope: str | None
72
- default: str
79
+ default: int
73
80
  values: list[str]
74
81
 
75
82
  def to_dict(self):
@@ -78,9 +85,11 @@ class _SelectOption:
78
85
  "name": self.name,
79
86
  "standard": self.standard,
80
87
  "advanced": self.advanced,
81
- "def": self.values.index(self.default),
88
+ "def": self.default,
82
89
  "values": self.values,
83
90
  }
91
+ if self.description is not None:
92
+ result["description"] = self.description
84
93
  if self.scope is not None:
85
94
  result["scope"] = self.scope
86
95
  return result
@@ -89,6 +98,7 @@ class _SelectOption:
89
98
  def slider_option(
90
99
  *,
91
100
  name: str | None = None,
101
+ description: str | None = None,
92
102
  standard: bool = False,
93
103
  advanced: bool = False,
94
104
  default: float,
@@ -102,6 +112,7 @@ def slider_option(
102
112
 
103
113
  Args:
104
114
  name: The name of the option.
115
+ description: The description of the option.
105
116
  standard: Whether the option is standard.
106
117
  advanced: Whether the option is advanced.
107
118
  default: The default value of the option.
@@ -111,12 +122,13 @@ def slider_option(
111
122
  unit: The unit of the option.
112
123
  scope: The scope of the option.
113
124
  """
114
- return _SliderOption(name, standard, advanced, scope, default, min, max, step, unit)
125
+ return _SliderOption(name, description, standard, advanced, scope, default, min, max, step, unit)
115
126
 
116
127
 
117
128
  def toggle_option(
118
129
  *,
119
130
  name: str | None = None,
131
+ description: str | None = None,
120
132
  standard: bool = False,
121
133
  advanced: bool = False,
122
134
  default: bool,
@@ -126,20 +138,22 @@ def toggle_option(
126
138
 
127
139
  Args:
128
140
  name: The name of the option.
141
+ description: The description of the option.
129
142
  standard: Whether the option is standard.
130
143
  advanced: Whether the option is advanced.
131
144
  default: The default value of the option.
132
145
  scope: The scope of the option.
133
146
  """
134
- return _ToggleOption(name, standard, advanced, scope, default)
147
+ return _ToggleOption(name, description, standard, advanced, scope, default)
135
148
 
136
149
 
137
150
  def select_option(
138
151
  *,
139
152
  name: str | None = None,
153
+ description: str | None = None,
140
154
  standard: bool = False,
141
155
  advanced: bool = False,
142
- default: str,
156
+ default: str | int,
143
157
  values: list[str],
144
158
  scope: str | None = None,
145
159
  ) -> Any:
@@ -147,13 +161,16 @@ def select_option(
147
161
 
148
162
  Args:
149
163
  name: The name of the option.
164
+ description: The description of the option.
150
165
  standard: Whether the option is standard.
151
166
  advanced: Whether the option is advanced.
152
167
  default: The default value of the option.
153
168
  values: The values of the option.
154
169
  scope: The scope of the option.
155
170
  """
156
- return _SelectOption(name, standard, advanced, scope, default, values)
171
+ if isinstance(default, str):
172
+ default = values.index(default)
173
+ return _SelectOption(name, description, standard, advanced, scope, default, values)
157
174
 
158
175
 
159
176
  type Options = NewType("Options", Any)
sonolus/script/pointer.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from sonolus.backend.place import BlockPlace
2
+ from sonolus.script.internal.context import ctx
2
3
  from sonolus.script.internal.impl import meta_fn, validate_value
3
- from sonolus.script.internal.value import Value
4
+ from sonolus.script.internal.value import BackingSource, Value
4
5
  from sonolus.script.num import Num, _is_num
5
6
 
6
7
 
@@ -13,6 +14,7 @@ def _deref[T: Value](block: Num, offset: Num, type_: type[T]) -> T:
13
14
  block = block._as_py_()
14
15
  if not isinstance(block, int):
15
16
  raise TypeError("block must be an integer")
17
+ block = ctx().blocks(block)
16
18
  else:
17
19
  if not _is_num(block):
18
20
  raise TypeError("block must be a Num")
@@ -28,3 +30,11 @@ def _deref[T: Value](block: Num, offset: Num, type_: type[T]) -> T:
28
30
  if not (isinstance(type_, type) and issubclass(type_, Value)):
29
31
  raise TypeError("type_ must be a Value")
30
32
  return type_._from_place_(BlockPlace(block, offset))
33
+
34
+
35
+ @meta_fn
36
+ def _backing_deref[T: Value](source: BackingSource, type_: type[T]) -> T:
37
+ type_ = validate_value(type_)._as_py_()
38
+ if not isinstance(type_, type) or not issubclass(type_, Value):
39
+ raise TypeError("type_ must be a Value")
40
+ return type_._from_backing_source_(source)
sonolus/script/project.py CHANGED
@@ -1,9 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass
3
5
  from os import PathLike
4
6
  from pathlib import Path
5
- from typing import Self, TypedDict
7
+ from typing import ClassVar, Self, TypedDict
6
8
 
9
+ from sonolus.backend.optimize import optimize
10
+ from sonolus.backend.optimize.passes import CompilerPass
7
11
  from sonolus.script.archetype import ArchetypeSchema
8
12
  from sonolus.script.engine import Engine
9
13
  from sonolus.script.level import Level
@@ -39,27 +43,30 @@ class Project:
39
43
  """
40
44
  return Project(self.engine, levels, self.resources)
41
45
 
42
- def dev(self, build_dir: PathLike, port: int = 8080):
46
+ def dev(self, build_dir: PathLike, port: int = 8080, config: BuildConfig | None = None):
43
47
  """Start a development server for the project.
44
48
 
45
49
  Args:
46
50
  build_dir: The path to the build directory.
47
51
  port: The port of the development server.
52
+ config: The build configuration.
48
53
  """
49
54
  from sonolus.build.cli import build_collection, run_server
50
55
 
51
- build_collection(self, Path(build_dir))
56
+ build_collection(self, Path(build_dir), config)
52
57
  run_server(Path(build_dir) / "site", port=port)
53
58
 
54
- def build(self, build_dir: PathLike):
59
+ def build(self, build_dir: PathLike, config: BuildConfig | None = None):
55
60
  """Build the project.
56
61
 
57
62
  Args:
58
63
  build_dir: The path to the build directory.
64
+ config: The build configuration.
59
65
  """
60
66
  from sonolus.build.cli import build_project
61
67
 
62
- build_project(self, Path(build_dir))
68
+ config = config or BuildConfig()
69
+ build_project(self, Path(build_dir), config)
63
70
 
64
71
  def schema(self) -> ProjectSchema:
65
72
  """Generate the schema of the project.
@@ -74,3 +81,32 @@ class Project:
74
81
 
75
82
  class ProjectSchema(TypedDict):
76
83
  archetypes: list[ArchetypeSchema]
84
+
85
+
86
+ @dataclass
87
+ class BuildConfig:
88
+ """A configuration for building an engine package."""
89
+
90
+ MINIMAL_PASSES: ClassVar[Sequence[CompilerPass]] = optimize.MINIMAL_PASSES
91
+ """The minimal list of compiler passes."""
92
+
93
+ FAST_PASSES: ClassVar[Sequence[CompilerPass]] = optimize.FAST_PASSES
94
+ """The list of compiler passes for faster builds."""
95
+
96
+ STANDARD_PASSES: ClassVar[Sequence[CompilerPass]] = optimize.STANDARD_PASSES
97
+ """The standard list of compiler passes."""
98
+
99
+ passes: Sequence[CompilerPass] = optimize.STANDARD_PASSES
100
+ """The list of compiler passes to use."""
101
+
102
+ build_play: bool = True
103
+ """Whether to build the play package."""
104
+
105
+ build_watch: bool = True
106
+ """Whether to build the watch package."""
107
+
108
+ build_preview: bool = True
109
+ """Whether to build the preview package."""
110
+
111
+ build_tutorial: bool = True
112
+ """Whether to build the tutorial package."""
sonolus/script/quad.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from typing import Protocol, Self
4
4
 
5
5
  from sonolus.script.record import Record
6
+ from sonolus.script.values import zeros
6
7
  from sonolus.script.vec import Vec2, pnpoly
7
8
 
8
9
 
@@ -27,6 +28,16 @@ class Quad(Record):
27
28
  br: Vec2
28
29
  """The bottom-right corner of the quad."""
29
30
 
31
+ @classmethod
32
+ def from_quad(cls, value: QuadLike, /) -> Quad:
33
+ """Create a quad from a quad-like value."""
34
+ return cls(
35
+ bl=value.bl,
36
+ tl=value.tl,
37
+ tr=value.tr,
38
+ br=value.br,
39
+ )
40
+
30
41
  @property
31
42
  def center(self) -> Vec2:
32
43
  """The center of the quad."""
@@ -95,6 +106,44 @@ class Quad(Record):
95
106
  """Rotate the quad by the given angle about its center and return a new quad."""
96
107
  return self.rotate_about(angle, self.center)
97
108
 
109
+ def permute(self, count: int = 1, /) -> Self:
110
+ """Perform a cyclic permutation of the quad's vertices and return a new quad.
111
+
112
+ On a square, this operation is equivalent to rotating the square counterclockwise 90 degrees `count` times.
113
+
114
+ Negative values of `count` are allowed and will rotate the quad clockwise.
115
+
116
+ Args:
117
+ count: The number of vertices to shift. Defaults to 1.
118
+
119
+ Returns:
120
+ The permuted quad.
121
+ """
122
+ count = int(count % 4)
123
+ result = zeros(Quad)
124
+ match count:
125
+ case 0:
126
+ result.bl @= self.bl
127
+ result.tl @= self.tl
128
+ result.tr @= self.tr
129
+ result.br @= self.br
130
+ case 1:
131
+ result.bl @= self.br
132
+ result.tl @= self.bl
133
+ result.tr @= self.tl
134
+ result.br @= self.tr
135
+ case 2:
136
+ result.bl @= self.tr
137
+ result.tl @= self.br
138
+ result.tr @= self.bl
139
+ result.br @= self.tl
140
+ case 3:
141
+ result.bl @= self.tl
142
+ result.tl @= self.tr
143
+ result.tr @= self.br
144
+ result.br @= self.bl
145
+ return result
146
+
98
147
  def contains_point(self, point: Vec2, /) -> bool:
99
148
  """Check if the quad contains the given point.
100
149
 
@@ -248,7 +297,7 @@ class Rect(Record):
248
297
  return self.l <= point.x <= self.r and self.b <= point.y <= self.t
249
298
 
250
299
 
251
- class QuadLike(Protocol):
300
+ class _QuadLike(Protocol):
252
301
  """A protocol for types that can be used as quads."""
253
302
 
254
303
  @property
@@ -268,6 +317,11 @@ class QuadLike(Protocol):
268
317
  """The bottom-right corner of the quad."""
269
318
 
270
319
 
320
+ # PyCharm doesn't recognize attributes as satisfying the protocol.
321
+ type QuadLike = _QuadLike | Quad
322
+ """A type that can be used as a quad."""
323
+
324
+
271
325
  def flatten_quad(quad: QuadLike) -> tuple[float, float, float, float, float, float, float, float]:
272
326
  bl = quad.bl
273
327
  tl = quad.tl
sonolus/script/record.py CHANGED
@@ -16,7 +16,7 @@ from sonolus.script.internal.generic import (
16
16
  validate_type_spec,
17
17
  )
18
18
  from sonolus.script.internal.impl import meta_fn
19
- from sonolus.script.internal.value import Value
19
+ from sonolus.script.internal.value import DataValue, Value
20
20
  from sonolus.script.num import Num
21
21
 
22
22
 
@@ -182,11 +182,11 @@ class Record(GenericValue):
182
182
  return self
183
183
 
184
184
  @classmethod
185
- def _from_list_(cls, values: Iterable[float | BlockPlace]) -> Self:
185
+ def _from_list_(cls, values: Iterable[DataValue]) -> Self:
186
186
  iterator = iter(values)
187
187
  return cls(**{field.name: field.type._from_list_(iterator) for field in cls._fields})
188
188
 
189
- def _to_list_(self, level_refs: dict[Any, str] | None = None) -> list[float | str | BlockPlace]:
189
+ def _to_list_(self, level_refs: dict[Any, str] | None = None) -> list[DataValue | str]:
190
190
  result = []
191
191
  for field in self._fields:
192
192
  result.extend(self._value[field.name]._to_list_(level_refs))
@@ -206,8 +206,7 @@ class Record(GenericValue):
206
206
  raise TypeError("Record does not support set_")
207
207
 
208
208
  def _copy_from_(self, value: Self):
209
- if not isinstance(value, type(self)):
210
- raise TypeError("Cannot copy from different type")
209
+ value = self._accept_(value)
211
210
  for field in self._fields:
212
211
  field.__set__(self, field.__get__(value))
213
212
 
@@ -221,6 +220,12 @@ class Record(GenericValue):
221
220
  result._value = {field.name: field.type._alloc_() for field in cls._fields}
222
221
  return result
223
222
 
223
+ @classmethod
224
+ def _zero_(cls) -> Self:
225
+ result = object.__new__(cls)
226
+ result._value = {field.name: field.type._zero_() for field in cls._fields}
227
+ return result
228
+
224
229
  def __str__(self):
225
230
  return (
226
231
  f"{self.__class__.__name__}({", ".join(f"{field.name}={field.__get__(self)}" for field in self._fields)})"