sonolus.py 0.1.8__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (48) hide show
  1. sonolus/backend/finalize.py +2 -1
  2. sonolus/backend/optimize/constant_evaluation.py +2 -2
  3. sonolus/backend/optimize/copy_coalesce.py +16 -7
  4. sonolus/backend/optimize/optimize.py +12 -4
  5. sonolus/backend/optimize/passes.py +2 -1
  6. sonolus/backend/place.py +95 -14
  7. sonolus/backend/visitor.py +83 -12
  8. sonolus/build/cli.py +60 -9
  9. sonolus/build/collection.py +68 -26
  10. sonolus/build/compile.py +87 -30
  11. sonolus/build/engine.py +166 -40
  12. sonolus/build/level.py +2 -1
  13. sonolus/build/node.py +8 -1
  14. sonolus/build/project.py +30 -11
  15. sonolus/script/archetype.py +169 -51
  16. sonolus/script/array.py +12 -1
  17. sonolus/script/bucket.py +26 -8
  18. sonolus/script/debug.py +2 -2
  19. sonolus/script/effect.py +2 -2
  20. sonolus/script/engine.py +123 -15
  21. sonolus/script/internal/builtin_impls.py +21 -2
  22. sonolus/script/internal/constant.py +6 -2
  23. sonolus/script/internal/context.py +30 -25
  24. sonolus/script/internal/introspection.py +8 -1
  25. sonolus/script/internal/math_impls.py +2 -1
  26. sonolus/script/internal/transient.py +5 -1
  27. sonolus/script/internal/value.py +10 -2
  28. sonolus/script/interval.py +16 -0
  29. sonolus/script/iterator.py +17 -0
  30. sonolus/script/level.py +130 -8
  31. sonolus/script/metadata.py +32 -0
  32. sonolus/script/num.py +11 -2
  33. sonolus/script/options.py +5 -3
  34. sonolus/script/pointer.py +2 -0
  35. sonolus/script/project.py +41 -5
  36. sonolus/script/record.py +8 -3
  37. sonolus/script/runtime.py +61 -10
  38. sonolus/script/sprite.py +18 -1
  39. sonolus/script/ui.py +7 -3
  40. sonolus/script/values.py +8 -5
  41. sonolus/script/vec.py +28 -0
  42. {sonolus_py-0.1.8.dist-info → sonolus_py-0.2.0.dist-info}/METADATA +3 -2
  43. sonolus_py-0.2.0.dist-info/RECORD +90 -0
  44. {sonolus_py-0.1.8.dist-info → sonolus_py-0.2.0.dist-info}/WHEEL +1 -1
  45. sonolus_py-0.1.8.dist-info/RECORD +0 -89
  46. /sonolus/script/{print.py → printing.py} +0 -0
  47. {sonolus_py-0.1.8.dist-info → sonolus_py-0.2.0.dist-info}/entry_points.txt +0 -0
  48. {sonolus_py-0.1.8.dist-info → sonolus_py-0.2.0.dist-info}/licenses/LICENSE +0 -0
sonolus/script/engine.py CHANGED
@@ -1,9 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  from collections.abc import Callable
5
+ from os import PathLike
6
+ from pathlib import Path
4
7
  from typing import Any
5
8
 
6
- from sonolus.build.collection import Asset
9
+ from sonolus.build.collection import Asset, load_asset
7
10
  from sonolus.script.archetype import PlayArchetype, PreviewArchetype, WatchArchetype, _BaseArchetype
8
11
  from sonolus.script.bucket import Buckets, EmptyBuckets
9
12
  from sonolus.script.effect import Effects, EmptyEffects
@@ -13,12 +16,52 @@ from sonolus.script.instruction import (
13
16
  TutorialInstructionIcons,
14
17
  TutorialInstructions,
15
18
  )
19
+ from sonolus.script.metadata import AnyText, Tag, as_localization_text
16
20
  from sonolus.script.options import EmptyOptions, Options
17
21
  from sonolus.script.particle import EmptyParticles, Particles
18
22
  from sonolus.script.sprite import EmptySkin, Skin
19
23
  from sonolus.script.ui import UiConfig
20
24
 
21
25
 
26
+ class ExportedEngine:
27
+ """An exported Sonolus.py engine."""
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ item: dict,
33
+ thumbnail: bytes,
34
+ play_data: bytes,
35
+ watch_data: bytes,
36
+ preview_data: bytes,
37
+ tutorial_data: bytes,
38
+ rom: bytes | None = None,
39
+ configuration: bytes,
40
+ ) -> None:
41
+ self.item = item
42
+ self.thumbnail = thumbnail
43
+ self.play_data = play_data
44
+ self.watch_data = watch_data
45
+ self.preview_data = preview_data
46
+ self.tutorial_data = tutorial_data
47
+ self.rom = rom
48
+ self.configuration = configuration
49
+
50
+ def write_to_dir(self, path: PathLike):
51
+ """Write the exported engine to a directory."""
52
+ path = Path(path)
53
+ path.mkdir(parents=True, exist_ok=True)
54
+ (path / "item.json").write_text(json.dumps(self.item, ensure_ascii=False), encoding="utf-8")
55
+ (path / "thumbnail").write_bytes(self.thumbnail)
56
+ (path / "playData").write_bytes(self.play_data)
57
+ (path / "watchData").write_bytes(self.watch_data)
58
+ (path / "previewData").write_bytes(self.preview_data)
59
+ (path / "tutorialData").write_bytes(self.tutorial_data)
60
+ if self.rom is not None:
61
+ (path / "rom").write_bytes(self.rom)
62
+ (path / "configuration").write_bytes(self.configuration)
63
+
64
+
22
65
  class Engine:
23
66
  """A Sonolus.py engine.
24
67
 
@@ -33,6 +76,9 @@ class Engine:
33
76
  particle: The default particle for the engine.
34
77
  thumbnail: The thumbnail for the engine.
35
78
  data: The engine's modes and configurations.
79
+ tags: The tags of the engine.
80
+ description: The description of the engine.
81
+ meta: Additional metadata of the engine.
36
82
  """
37
83
 
38
84
  version = 12
@@ -41,26 +87,68 @@ class Engine:
41
87
  self,
42
88
  *,
43
89
  name: str,
44
- title: str | None = None,
45
- subtitle: str = "Sonolus.py Engine",
46
- author: str = "Unknown",
90
+ title: AnyText | None = None,
91
+ subtitle: AnyText = "Sonolus.py Engine",
92
+ author: AnyText = "Unknown",
47
93
  skin: str | None = None,
48
94
  background: str | None = None,
49
95
  effect: str | None = None,
50
96
  particle: str | None = None,
51
97
  thumbnail: Asset | None = None,
52
98
  data: EngineData,
99
+ tags: list[Tag] | None = None,
100
+ description: AnyText | None = None,
101
+ meta: Any = None,
53
102
  ) -> None:
54
103
  self.name = name
55
- self.title = title or name
56
- self.subtitle = subtitle
57
- self.author = author
104
+ self.title = as_localization_text(title or name)
105
+ self.subtitle = as_localization_text(subtitle)
106
+ self.author = as_localization_text(author)
58
107
  self.skin = skin
59
108
  self.background = background
60
109
  self.effect = effect
61
110
  self.particle = particle
62
111
  self.thumbnail = thumbnail
63
112
  self.data = data
113
+ self.tags = tags or []
114
+ self.description = as_localization_text(description) if description is not None else None
115
+ self.meta = meta
116
+
117
+ def export(self) -> ExportedEngine:
118
+ """Export the engine in a sonolus-pack compatible format.
119
+
120
+ Returns:
121
+ An exported engine.
122
+ """
123
+ from sonolus.build.engine import package_engine
124
+ from sonolus.build.project import BLANK_PNG
125
+
126
+ item = {
127
+ "version": self.version,
128
+ "title": self.title,
129
+ "subtitle": self.subtitle,
130
+ "author": self.author,
131
+ "tags": [tag.as_dict() for tag in self.tags],
132
+ "skin": self.skin,
133
+ "background": self.background,
134
+ "effect": self.effect,
135
+ "particle": self.particle,
136
+ }
137
+ packaged = package_engine(self.data)
138
+ if self.description is not None:
139
+ item["description"] = self.description
140
+ if self.meta is not None:
141
+ item["meta"] = self.meta
142
+ return ExportedEngine(
143
+ item=item,
144
+ thumbnail=load_asset(self.thumbnail) if self.thumbnail is not None else BLANK_PNG,
145
+ play_data=packaged.play_data,
146
+ watch_data=packaged.watch_data,
147
+ preview_data=packaged.preview_data,
148
+ tutorial_data=packaged.tutorial_data,
149
+ rom=packaged.rom,
150
+ configuration=packaged.configuration,
151
+ )
64
152
 
65
153
 
66
154
  def default_callback() -> Any:
@@ -190,6 +278,30 @@ class TutorialMode:
190
278
  self.update = update
191
279
 
192
280
 
281
+ def empty_play_mode() -> PlayMode:
282
+ """Create an empty play mode."""
283
+ return PlayMode()
284
+
285
+
286
+ def empty_watch_mode() -> WatchMode:
287
+ """Create an empty watch mode."""
288
+ return WatchMode(update_spawn=default_callback)
289
+
290
+
291
+ def empty_preview_mode() -> PreviewMode:
292
+ """Create an empty preview mode."""
293
+ return PreviewMode()
294
+
295
+
296
+ def empty_tutorial_mode() -> TutorialMode:
297
+ """Create an empty tutorial mode."""
298
+ return TutorialMode(
299
+ preprocess=default_callback,
300
+ navigate=default_callback,
301
+ update=default_callback,
302
+ )
303
+
304
+
193
305
  class EngineData:
194
306
  """A Sonolus.py engine's modes and configurations.
195
307
 
@@ -214,11 +326,7 @@ class EngineData:
214
326
  ) -> None:
215
327
  self.ui = ui or UiConfig()
216
328
  self.options = options
217
- self.play = play or PlayMode()
218
- self.watch = watch or WatchMode(update_spawn=default_callback)
219
- self.preview = preview or PreviewMode()
220
- self.tutorial = tutorial or TutorialMode(
221
- preprocess=default_callback,
222
- navigate=default_callback,
223
- update=default_callback,
224
- )
329
+ self.play = play or empty_play_mode()
330
+ self.watch = watch or empty_watch_mode()
331
+ self.preview = preview or empty_preview_mode()
332
+ self.tutorial = tutorial or empty_tutorial_mode()
@@ -52,9 +52,11 @@ def _enumerate(iterable, start=0):
52
52
  from sonolus.backend.visitor import compile_and_call
53
53
 
54
54
  iterable = validate_value(iterable)
55
- if not hasattr(iterable, "__iter__"):
55
+ if isinstance(iterable, TupleImpl):
56
+ return TupleImpl._accept_(tuple((start + i, value) for i, value in enumerate(iterable._as_py_(), start=start)))
57
+ elif not hasattr(iterable, "__iter__"):
56
58
  raise TypeError(f"'{type(iterable).__name__}' object is not iterable")
57
- if isinstance(iterable, ArrayLike):
59
+ elif isinstance(iterable, ArrayLike):
58
60
  return compile_and_call(iterable._enumerate_, start)
59
61
  else:
60
62
  iterator = compile_and_call(iterable.__iter__)
@@ -232,10 +234,27 @@ _int._type_mapping_ = Num
232
234
  _float._type_mapping_ = Num
233
235
  _bool._type_mapping_ = Num
234
236
 
237
+
238
+ def _any(iterable):
239
+ for value in iterable: # noqa: SIM110
240
+ if value:
241
+ return True
242
+ return False
243
+
244
+
245
+ def _all(iterable):
246
+ for value in iterable: # noqa: SIM110
247
+ if not value:
248
+ return False
249
+ return True
250
+
251
+
235
252
  # classmethod, property, staticmethod are supported as decorators, but not within functions
236
253
 
237
254
  BUILTIN_IMPLS = {
238
255
  id(abs): _abs,
256
+ id(all): _all,
257
+ id(any): _any,
239
258
  id(bool): _bool,
240
259
  id(callable): _callable,
241
260
  id(enumerate): _enumerate,
@@ -97,7 +97,7 @@ class ConstantValue(Value):
97
97
  def _from_list_(cls, values: Iterable[float | BlockPlace]) -> Self:
98
98
  return cls()
99
99
 
100
- def _to_list_(self, level_refs: dict[Any, int] | None = None) -> list[float | BlockPlace]:
100
+ def _to_list_(self, level_refs: dict[Any, str] | None = None) -> list[float | str | BlockPlace]:
101
101
  return []
102
102
 
103
103
  @classmethod
@@ -107,7 +107,7 @@ class ConstantValue(Value):
107
107
  def _get_(self) -> Self:
108
108
  return self
109
109
 
110
- def _set_(self, value: Any) -> Self:
110
+ def _set_(self, value: Any):
111
111
  if value is not self:
112
112
  raise ValueError(f"{type(self).__name__} is immutable")
113
113
 
@@ -122,6 +122,10 @@ class ConstantValue(Value):
122
122
  def _alloc_(cls) -> Self:
123
123
  return cls()
124
124
 
125
+ @classmethod
126
+ def _zero_(cls) -> Self:
127
+ return cls()
128
+
125
129
  @meta_fn
126
130
  def __eq__(self, other):
127
131
  return self is other
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from contextlib import contextmanager
4
4
  from contextvars import ContextVar
5
5
  from dataclasses import dataclass
6
+ from threading import Lock
6
7
  from typing import Any, Self
7
8
 
8
9
  from sonolus.backend.blocks import BlockData, PlayBlock
@@ -25,6 +26,7 @@ class GlobalContextState:
25
26
  environment_mappings: dict[_GlobalInfo, int]
26
27
  environment_offsets: dict[Block, int]
27
28
  mode: Mode
29
+ lock: Lock
28
30
 
29
31
  def __init__(self, mode: Mode, archetypes: dict[type, int] | None = None, rom: ReadOnlyMemory | None = None):
30
32
  self.archetypes = archetypes or {}
@@ -33,6 +35,7 @@ class GlobalContextState:
33
35
  self.environment_mappings = {}
34
36
  self.environment_offsets = {}
35
37
  self.mode = mode
38
+ self.lock = Lock()
36
39
 
37
40
 
38
41
  class CallbackContextState:
@@ -88,10 +91,6 @@ class Context:
88
91
  def used_names(self) -> dict[str, int]:
89
92
  return self.callback_state.used_names
90
93
 
91
- @property
92
- def const_mappings(self) -> dict[Any, int]:
93
- return self.global_state.const_mappings
94
-
95
94
  def check_readable(self, place: BlockPlace):
96
95
  if not self.callback:
97
96
  return
@@ -204,22 +203,25 @@ class Context:
204
203
  )
205
204
 
206
205
  def map_constant(self, value: Any) -> int:
207
- if value not in self.const_mappings:
208
- self.const_mappings[value] = len(self.const_mappings)
209
- return self.const_mappings[value]
206
+ with self.global_state.lock:
207
+ const_mappings = self.global_state.const_mappings
208
+ if value not in const_mappings:
209
+ const_mappings[value] = len(const_mappings)
210
+ return const_mappings[value]
210
211
 
211
212
  def get_global_base(self, value: _GlobalInfo | _GlobalPlaceholder) -> BlockPlace:
212
- block = value.blocks.get(self.global_state.mode)
213
- if block is None:
214
- raise RuntimeError(f"Global {value.name} is not available in '{self.global_state.mode.name}' mode")
215
- if value not in self.global_state.environment_mappings:
216
- if value.offset is None:
217
- offset = self.global_state.environment_offsets.get(block, 0)
218
- self.global_state.environment_mappings[value] = offset
219
- self.global_state.environment_offsets[block] = offset + value.size
220
- else:
221
- self.global_state.environment_mappings[value] = value.offset
222
- return BlockPlace(block, self.global_state.environment_mappings[value])
213
+ with self.global_state.lock:
214
+ block = value.blocks.get(self.global_state.mode)
215
+ if block is None:
216
+ raise RuntimeError(f"Global {value.name} is not available in '{self.global_state.mode.name}' mode")
217
+ if value not in self.global_state.environment_mappings:
218
+ if value.offset is None:
219
+ offset = self.global_state.environment_offsets.get(block, 0)
220
+ self.global_state.environment_mappings[value] = offset
221
+ self.global_state.environment_offsets[block] = offset + value.size
222
+ else:
223
+ self.global_state.environment_mappings[value] = value.offset
224
+ return BlockPlace(block, self.global_state.environment_mappings[value])
223
225
 
224
226
  @classmethod
225
227
  def meet(cls, contexts: list[Context]) -> Context:
@@ -257,19 +259,22 @@ def using_ctx(value: Context | None):
257
259
  class ReadOnlyMemory:
258
260
  values: list[float]
259
261
  indexes: dict[tuple[float, ...], int]
262
+ _lock: Lock
260
263
 
261
264
  def __init__(self):
262
265
  self.values = []
263
266
  self.indexes = {}
267
+ self._lock = Lock()
264
268
 
265
269
  def __getitem__(self, item: tuple[float, ...]) -> BlockPlace:
266
- if item not in self.indexes:
267
- index = len(self.values)
268
- self.indexes[item] = index
269
- self.values.extend(item)
270
- else:
271
- index = self.indexes[item]
272
- return BlockPlace(self.block, index)
270
+ with self._lock:
271
+ if item not in self.indexes:
272
+ index = len(self.values)
273
+ self.indexes[item] = index
274
+ self.values.extend(item)
275
+ else:
276
+ index = self.indexes[item]
277
+ return BlockPlace(self.block, index)
273
278
 
274
279
  @property
275
280
  def block(self) -> Block:
@@ -12,6 +12,13 @@ def get_field_specifiers(cls, *, skip: set[str] = frozenset(), globals=None, loc
12
12
  if class_value is not _missing and key not in skip:
13
13
  results[key] = Annotated[value, class_value]
14
14
  for key, value in cls.__dict__.items():
15
- if key not in results and key not in skip and not key.startswith("__") and not callable(value):
15
+ if (
16
+ key not in results
17
+ and key not in skip
18
+ and not key.startswith("__")
19
+ and not callable(value)
20
+ and not hasattr(value, "__func__")
21
+ and not isinstance(value, property)
22
+ ):
16
23
  raise ValueError(f"Missing annotation for {cls.__name__}.{key}")
17
24
  return results
@@ -93,7 +93,8 @@ def _ln(x: float) -> float:
93
93
  def _log(x: float, base: float | None = None) -> float:
94
94
  if base is None:
95
95
  return _ln(x)
96
- return _ln(x) / _ln(base)
96
+ else:
97
+ return _ln(x) / _ln(base)
97
98
 
98
99
 
99
100
  @native_function(Op.Rem)
@@ -26,7 +26,7 @@ class TransientValue(Value):
26
26
  def _from_list_(cls, values: Iterable[float | BlockPlace]) -> Self:
27
27
  raise TypeError(f"{cls.__name__} cannot be constructed from list")
28
28
 
29
- def _to_list_(self, level_refs: dict[Any, int] | None = None) -> list[float | BlockPlace]:
29
+ def _to_list_(self, level_refs: dict[Any, str] | None = None) -> list[float | str | BlockPlace]:
30
30
  raise TypeError(f"{type(self).__name__} cannot be deconstructed to list")
31
31
 
32
32
  @classmethod
@@ -49,3 +49,7 @@ class TransientValue(Value):
49
49
  @classmethod
50
50
  def _alloc_(cls) -> Self:
51
51
  raise TypeError(f"{cls.__name__} is not allocatable")
52
+
53
+ @classmethod
54
+ def _zero_(cls) -> Self:
55
+ raise TypeError(f"{cls.__name__} does not have a zero value")
@@ -66,7 +66,7 @@ class Value:
66
66
  raise NotImplementedError
67
67
 
68
68
  @abstractmethod
69
- def _to_list_(self, level_refs: dict[Any, int] | None = None) -> list[float | BlockPlace]:
69
+ def _to_list_(self, level_refs: dict[Any, str] | None = None) -> list[float | str | BlockPlace]:
70
70
  """Converts this value to a list of floats."""
71
71
  raise NotImplementedError
72
72
 
@@ -76,7 +76,9 @@ class Value:
76
76
  """Returns the keys to a flat representation of this value."""
77
77
  raise NotImplementedError
78
78
 
79
- def _to_flat_dict_(self, prefix: str, level_refs: dict[Any, int] | None = None) -> dict[str, float | BlockPlace]:
79
+ def _to_flat_dict_(
80
+ self, prefix: str, level_refs: dict[Any, str] | None = None
81
+ ) -> dict[str, float | str | BlockPlace]:
80
82
  """Converts this value to a flat dictionary."""
81
83
  return dict(zip(self._flat_keys_(prefix), self._to_list_(level_refs), strict=False))
82
84
 
@@ -136,6 +138,12 @@ class Value:
136
138
  """Allocates a new value which may be uninitialized."""
137
139
  raise NotImplementedError
138
140
 
141
+ @classmethod
142
+ @abstractmethod
143
+ def _zero_(cls) -> Self:
144
+ """Returns a zero-initialized value of this type."""
145
+ raise NotImplementedError
146
+
139
147
  def __imatmul__(self, other):
140
148
  self._copy_from_(other)
141
149
  return self
@@ -19,6 +19,22 @@ class Interval(Record):
19
19
  start: float
20
20
  end: float
21
21
 
22
+ @classmethod
23
+ def zero(cls) -> Self:
24
+ """Get an empty interval."""
25
+ return cls(0, 0)
26
+
27
+ def then(self, length: float) -> Self:
28
+ """Get the interval after this one with a given length.
29
+
30
+ Args:
31
+ length: The length of the interval.
32
+
33
+ Returns:
34
+ An interval that has the end of this interval as the start and has the given length.
35
+ """
36
+ return Interval(self.end, self.end + length)
37
+
22
38
  @property
23
39
  def length(self) -> float:
24
40
  """The length of the interval.
@@ -165,3 +165,20 @@ class _FilteringIterator[T, Fn](Record, SonolusIterator):
165
165
 
166
166
  def advance(self):
167
167
  self.iterator.advance()
168
+
169
+
170
+ class _ChainingIterator[T](Record, SonolusIterator):
171
+ iterator: T
172
+
173
+ def has_next(self) -> bool:
174
+ return self.iterator.has_next()
175
+
176
+ def get(self) -> Any:
177
+ return self.iterator.get().get()
178
+
179
+ def advance(self):
180
+ self.iterator.get().advance()
181
+ while not self.iterator.get().has_next():
182
+ self.iterator.advance()
183
+ if not self.iterator.has_next():
184
+ break
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,22 +68,100 @@ 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
+ )
146
+
147
+
148
+ type EntityListArg = list[PlayArchetype | EntityListArg]
149
+
150
+
151
+ def flatten_entities(entities: EntityListArg) -> Iterator[PlayArchetype]:
152
+ """Flatten a list of entities.
153
+
154
+ Args:
155
+ entities: The list of entities.
156
+
157
+ Yields:
158
+ The flattened entities.
159
+ """
160
+ if isinstance(entities, list):
161
+ for entity in entities:
162
+ yield from flatten_entities(entity)
163
+ else:
164
+ yield entities
43
165
 
44
166
 
45
167
  class LevelData:
@@ -55,7 +177,7 @@ class LevelData:
55
177
 
56
178
  def __init__(self, bgm_offset: float, entities: list[PlayArchetype]) -> None:
57
179
  self.bgm_offset = bgm_offset
58
- self.entities = entities
180
+ self.entities = [*flatten_entities(entities)]
59
181
 
60
182
 
61
183
  class BpmChange(PlayArchetype):