sonolus.py 0.1.9__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 (42) 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 +110 -26
  13. sonolus/script/array.py +11 -0
  14. sonolus/script/debug.py +2 -2
  15. sonolus/script/effect.py +2 -2
  16. sonolus/script/engine.py +123 -15
  17. sonolus/script/internal/builtin_impls.py +21 -2
  18. sonolus/script/internal/constant.py +5 -1
  19. sonolus/script/internal/context.py +30 -25
  20. sonolus/script/internal/math_impls.py +2 -1
  21. sonolus/script/internal/transient.py +4 -0
  22. sonolus/script/internal/value.py +6 -0
  23. sonolus/script/interval.py +16 -0
  24. sonolus/script/iterator.py +17 -0
  25. sonolus/script/level.py +113 -10
  26. sonolus/script/metadata.py +32 -0
  27. sonolus/script/num.py +9 -0
  28. sonolus/script/options.py +5 -3
  29. sonolus/script/pointer.py +2 -0
  30. sonolus/script/project.py +41 -5
  31. sonolus/script/record.py +7 -2
  32. sonolus/script/runtime.py +61 -10
  33. sonolus/script/sprite.py +18 -1
  34. sonolus/script/ui.py +7 -3
  35. sonolus/script/values.py +8 -5
  36. sonolus/script/vec.py +28 -0
  37. {sonolus_py-0.1.9.dist-info → sonolus_py-0.2.0.dist-info}/METADATA +3 -2
  38. {sonolus_py-0.1.9.dist-info → sonolus_py-0.2.0.dist-info}/RECORD +42 -41
  39. {sonolus_py-0.1.9.dist-info → sonolus_py-0.2.0.dist-info}/WHEEL +1 -1
  40. /sonolus/script/{print.py → printing.py} +0 -0
  41. {sonolus_py-0.1.9.dist-info → sonolus_py-0.2.0.dist-info}/entry_points.txt +0 -0
  42. {sonolus_py-0.1.9.dist-info → sonolus_py-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -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:
@@ -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)
@@ -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")
@@ -138,6 +138,12 @@ class Value:
138
138
  """Allocates a new value which may be uninitialized."""
139
139
  raise NotImplementedError
140
140
 
141
+ @classmethod
142
+ @abstractmethod
143
+ def _zero_(cls) -> Self:
144
+ """Returns a zero-initialized value of this type."""
145
+ raise NotImplementedError
146
+
141
147
  def __imatmul__(self, other):
142
148
  self._copy_from_(other)
143
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,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
@@ -134,6 +134,15 @@ class _Num(Value, metaclass=_NumMeta):
134
134
  else:
135
135
  return Num(-1)
136
136
 
137
+ @classmethod
138
+ def _zero_(cls) -> Self:
139
+ if ctx():
140
+ result_place = ctx().alloc(size=1)
141
+ ctx().add_statements(IRSet(result_place, IRConst(0)))
142
+ return cls(result_place)
143
+ else:
144
+ return cls(0)
145
+
137
146
  def ir(self):
138
147
  if isinstance(self.data, BlockPlace):
139
148
  return IRGet(self.data)
sonolus/script/options.py CHANGED
@@ -69,7 +69,7 @@ class _SelectOption:
69
69
  standard: bool
70
70
  advanced: bool
71
71
  scope: str | None
72
- default: str
72
+ default: int
73
73
  values: list[str]
74
74
 
75
75
  def to_dict(self):
@@ -78,7 +78,7 @@ class _SelectOption:
78
78
  "name": self.name,
79
79
  "standard": self.standard,
80
80
  "advanced": self.advanced,
81
- "def": self.values.index(self.default),
81
+ "def": self.default,
82
82
  "values": self.values,
83
83
  }
84
84
  if self.scope is not None:
@@ -139,7 +139,7 @@ def select_option(
139
139
  name: str | None = None,
140
140
  standard: bool = False,
141
141
  advanced: bool = False,
142
- default: str,
142
+ default: str | int,
143
143
  values: list[str],
144
144
  scope: str | None = None,
145
145
  ) -> Any:
@@ -153,6 +153,8 @@ def select_option(
153
153
  values: The values of the option.
154
154
  scope: The scope of the option.
155
155
  """
156
+ if isinstance(default, str):
157
+ default = values.index(default)
156
158
  return _SelectOption(name, standard, advanced, scope, default, values)
157
159
 
158
160
 
sonolus/script/pointer.py CHANGED
@@ -1,4 +1,5 @@
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
4
  from sonolus.script.internal.value import Value
4
5
  from sonolus.script.num import Num, _is_num
@@ -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")
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/record.py CHANGED
@@ -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)})"
sonolus/script/runtime.py CHANGED
@@ -103,6 +103,12 @@ class _PreviewRuntimeCanvas:
103
103
  scroll_direction: ScrollDirection
104
104
  size: float
105
105
 
106
+ def update(self, scroll_direction: ScrollDirection | None = None, size: float | None = None):
107
+ if scroll_direction is not None:
108
+ self.scroll_direction = scroll_direction
109
+ if size is not None:
110
+ self.size = size
111
+
106
112
 
107
113
  class RuntimeUiConfig(Record):
108
114
  scale: float
@@ -253,36 +259,57 @@ class _TutorialRuntimeUi:
253
259
 
254
260
 
255
261
  class Touch(Record):
262
+ """Data of a touch event."""
263
+
256
264
  id: int
265
+ """The unique identifier of the touch."""
266
+
257
267
  started: bool
268
+ """Whether the touch has started this frame."""
269
+
258
270
  ended: bool
271
+ """Whether the touch has ended this frame."""
272
+
259
273
  time: float
274
+ """The time of the touch event.
275
+
276
+ May remain constant while there is no movement.
277
+ """
278
+
260
279
  start_time: float
280
+ """The time the touch started."""
281
+
261
282
  position: Vec2
283
+ """The current position of the touch."""
284
+
262
285
  start_position: Vec2
286
+ """The position the touch started."""
287
+
263
288
  delta: Vec2
289
+ """The change in position of the touch."""
290
+
264
291
  velocity: Vec2
292
+ """The velocity of the touch."""
293
+
265
294
  speed: float
295
+ """The speed of the touch's movement."""
296
+
266
297
  angle: float
298
+ """The angle of the touch's movement."""
267
299
 
268
300
  @property
269
- def total_time(self) -> float:
270
- return self.time - self.start_time
301
+ def prev_position(self) -> Vec2:
302
+ """The previous position of the touch."""
303
+ return self.position - self.delta
271
304
 
272
305
  @property
273
306
  def total_delta(self) -> Vec2:
307
+ """The total change in position of the touch."""
274
308
  return self.position - self.start_position
275
309
 
276
- @property
277
- def total_velocity(self) -> Vec2:
278
- return self.total_delta / self.total_time if self.total_time > 0 else Vec2(0, 0)
279
-
280
- @property
281
- def total_speed(self) -> float:
282
- return self.total_velocity.magnitude
283
-
284
310
  @property
285
311
  def total_angle(self) -> float:
312
+ """The total angle of the touch's movement."""
286
313
  return self.total_delta.angle
287
314
 
288
315
 
@@ -449,6 +476,30 @@ def is_debug() -> bool:
449
476
  return False
450
477
 
451
478
 
479
+ @meta_fn
480
+ def is_play() -> bool:
481
+ """Check if the game is running in play mode."""
482
+ return ctx() and ctx().global_state.mode == Mode.PLAY
483
+
484
+
485
+ @meta_fn
486
+ def is_preview() -> bool:
487
+ """Check if the game is running in preview mode."""
488
+ return ctx() and ctx().global_state.mode == Mode.PREVIEW
489
+
490
+
491
+ @meta_fn
492
+ def is_watch() -> bool:
493
+ """Check if the game is running in watch mode."""
494
+ return ctx() and ctx().global_state.mode == Mode.WATCH
495
+
496
+
497
+ @meta_fn
498
+ def is_tutorial() -> bool:
499
+ """Check if the game is running in tutorial mode."""
500
+ return ctx() and ctx().global_state.mode == Mode.TUTORIAL
501
+
502
+
452
503
  @meta_fn
453
504
  def aspect_ratio() -> float:
454
505
  """Get the aspect ratio of the game."""