typescratch 1.3.0__tar.gz → 1.6.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {typescratch-1.3.0 → typescratch-1.6.2}/PKG-INFO +2 -1
  2. {typescratch-1.3.0 → typescratch-1.6.2}/pyproject.toml +7 -2
  3. {typescratch-1.3.0/typescratch_pypi_release/typescratch_src → typescratch-1.6.2/typescratch}/__init__.py +42 -1
  4. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch/ast_nodes.py +36 -0
  5. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch/blocks.py +46 -5
  6. typescratch-1.6.2/typescratch/cli.py +171 -0
  7. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch/codegen.py +129 -3
  8. typescratch-1.6.2/typescratch/decompiler.py +871 -0
  9. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch/ide.py +14 -6
  10. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch/lexer.py +3 -0
  11. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch/logo.svg +0 -0
  12. typescratch-1.6.2/typescratch/logo_ascii.txt +5 -0
  13. typescratch-1.6.2/typescratch/meow.txt +48 -0
  14. typescratch-1.6.2/typescratch/package_manager.py +127 -0
  15. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch/parser.py +129 -8
  16. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch.egg-info/PKG-INFO +2 -1
  17. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch.egg-info/SOURCES.txt +8 -0
  18. typescratch-1.6.2/typescratch.egg-info/requires.txt +3 -0
  19. {typescratch-1.3.0/typescratch → typescratch-1.6.2/typescratch_pypi_release/typescratch_src}/__init__.py +42 -1
  20. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/ast_nodes.py +36 -0
  21. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/blocks.py +13 -5
  22. typescratch-1.6.2/typescratch_pypi_release/typescratch_src/cli.py +171 -0
  23. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/codegen.py +156 -24
  24. typescratch-1.6.2/typescratch_pypi_release/typescratch_src/decompiler.py +868 -0
  25. typescratch-1.6.2/typescratch_pypi_release/typescratch_src/ide.py +515 -0
  26. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/lexer.py +3 -0
  27. typescratch-1.6.2/typescratch_pypi_release/typescratch_src/package_manager.py +127 -0
  28. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/parser.py +129 -8
  29. typescratch-1.3.0/typescratch/cli.py +0 -76
  30. typescratch-1.3.0/typescratch_pypi_release/typescratch_src/cli.py +0 -67
  31. {typescratch-1.3.0 → typescratch-1.6.2}/LICENSE +0 -0
  32. {typescratch-1.3.0 → typescratch-1.6.2}/README.md +0 -0
  33. {typescratch-1.3.0 → typescratch-1.6.2}/setup.cfg +0 -0
  34. {typescratch-1.3.0 → typescratch-1.6.2}/setup.py +0 -0
  35. {typescratch-1.3.0 → typescratch-1.6.2}/tests/test_basic.py +0 -0
  36. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch/__main__.py +0 -0
  37. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch/assets.py +0 -0
  38. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch/errors.py +0 -0
  39. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch/sb3.py +0 -0
  40. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch/scratchcat_official.svg +0 -0
  41. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch.egg-info/dependency_links.txt +0 -0
  42. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch.egg-info/entry_points.txt +0 -0
  43. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch.egg-info/top_level.txt +0 -0
  44. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch_pypi_release/setup.py +0 -0
  45. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/__main__.py +0 -0
  46. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/assets.py +0 -0
  47. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/errors.py +0 -0
  48. {typescratch-1.3.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/sb3.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typescratch
3
- Version: 1.3.0
3
+ Version: 1.6.2
4
4
  Summary: A programming language that compiles to Scratch 3 (.sb3) files
5
5
  Author: TypeScratch
6
6
  License: Apache-2.0
@@ -23,6 +23,7 @@ Classifier: Topic :: Software Development :: Compilers
23
23
  Requires-Python: >=3.8
24
24
  Description-Content-Type: text/markdown
25
25
  License-File: LICENSE
26
+ Requires-Dist: windows-curses>=2.0; sys_platform == "win32"
26
27
  Dynamic: license-file
27
28
 
28
29
  # TypeScratch
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "typescratch"
7
- version = "1.3.0"
7
+ version = "1.6.2"
8
8
  description = "A programming language that compiles to Scratch 3 (.sb3) files"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -25,6 +25,11 @@ classifiers = [
25
25
  "Topic :: Education",
26
26
  "Topic :: Software Development :: Compilers",
27
27
  ]
28
+ # On Windows, curses is not built-in, so we need windows-curses.
29
+ # The platform marker ensures pip only installs it on Windows.
30
+ dependencies = [
31
+ "windows-curses>=2.0; sys_platform == 'win32'",
32
+ ]
28
33
 
29
34
  [project.scripts]
30
35
  typescratch = "typescratch.cli:main"
@@ -39,4 +44,4 @@ include = ["typescratch*"]
39
44
  exclude = ["tests*"]
40
45
 
41
46
  [tool.setuptools.package-data]
42
- typescratch = ["*.svg", "*.wav"]
47
+ typescratch = ["*.svg", "*.wav", "*.txt"]
@@ -46,7 +46,7 @@ from .codegen import Codegen
46
46
  from .sb3 import write_sb3, write_sb3_bytes
47
47
 
48
48
 
49
- __version__ = "1.2.0"
49
+ __version__ = "1.6.2"
50
50
 
51
51
 
52
52
  def compile_source(src: str, filename: str = "<tysh>", debug: bool = False,
@@ -58,6 +58,10 @@ def compile_source(src: str, filename: str = "<tysh>", debug: bool = False,
58
58
  If None, defaults to the directory of `filename` (or cwd).
59
59
  """
60
60
  dbg = Debug(enabled=debug)
61
+
62
+ # Preprocess: handle `fuse "file.tysh"` directives
63
+ src = _preprocess_fuse(src, filename, dbg)
64
+
61
65
  dbg.section("Tokens")
62
66
  toks = tokenize(src, filename)
63
67
  for t in toks:
@@ -83,6 +87,43 @@ def compile_source(src: str, filename: str = "<tysh>", debug: bool = False,
83
87
  return project, assets
84
88
 
85
89
 
90
+ def _preprocess_fuse(src: str, filename: str, dbg: Debug) -> str:
91
+ """Handle `fuse "file.tysh"` directives by inlining file contents.
92
+
93
+ The fuse directive must be on its own line. The file path is relative
94
+ to the directory of the source file.
95
+ """
96
+ import re
97
+ base_dir = os.path.dirname(os.path.abspath(filename)) if filename != "<tysh>" else os.getcwd()
98
+
99
+ lines = src.split("\n")
100
+ result = []
101
+ for line in lines:
102
+ stripped = line.strip()
103
+ # Match: fuse "file.tysh" or fuse 'file.tysh'
104
+ m = re.match(r'^fuse\s+["\']([^"\']+)["\']\s*$', stripped)
105
+ if m:
106
+ fuse_path = m.group(1)
107
+ if not os.path.isabs(fuse_path):
108
+ fuse_path = os.path.join(base_dir, fuse_path)
109
+ if os.path.isfile(fuse_path):
110
+ dbg.line(f"Fusing: {fuse_path}")
111
+ with open(fuse_path, "r", encoding="utf-8") as f:
112
+ fused_content = f.read()
113
+ # Recursively process fuse directives in the fused file
114
+ fused_content = _preprocess_fuse(fused_content, fuse_path, dbg)
115
+ result.append(f"// === fused from {m.group(1)} ===")
116
+ result.extend(fused_content.split("\n"))
117
+ result.append(f"// === end fuse ===")
118
+ else:
119
+ from .errors import ForgotOops
120
+ raise ForgotOops(f'Cannot fuse file "{m.group(1)}" - file not found')
121
+ else:
122
+ result.append(line)
123
+
124
+ return "\n".join(result)
125
+
126
+
86
127
  def source(src: str, out: Optional[str] = None, debug: bool = False,
87
128
  filename: str = "<tysh>") -> bytes:
88
129
  """Compile a TypeScratch source string and return the .sb3 bytes.
@@ -361,3 +361,39 @@ class ListReporterRef:
361
361
  method: str # 'item' | 'length' | 'contains' | 'itemNum' | 'contents'
362
362
  args: List[Any]
363
363
  line: int = 0
364
+
365
+
366
+ @dataclass
367
+ class TernaryExpr:
368
+ """Ternary expression: [if (cond) true_val else false_val]
369
+
370
+ Desugared at codegen time into if/else that sets a temp variable.
371
+ """
372
+ cond: Any
373
+ true_expr: Any
374
+ false_expr: Any
375
+ line: int = 0
376
+
377
+
378
+ @dataclass
379
+ class SoundImport:
380
+ """sound name from "path.wav" - imports a sound file."""
381
+ name: str
382
+ path: str
383
+ line: int = 0
384
+
385
+
386
+ @dataclass
387
+ class CostumeImport:
388
+ """costume name from "path.png" - imports a costume file."""
389
+ name: str
390
+ path: str
391
+ line: int = 0
392
+
393
+
394
+ @dataclass
395
+ class LocalVarDecl:
396
+ """private x = value - declares a local variable inside a custom block."""
397
+ name: str
398
+ value: Any
399
+ line: int = 0
@@ -153,25 +153,51 @@ STACK_BLOCKS = {
153
153
  "args": [],
154
154
  },
155
155
  "goToFront": {
156
+ "opcode": "looks_gotofrontback",
157
+ "fields": [("FRONT_BACK", "front")],
158
+ },
159
+ "goToBack": {
160
+ "opcode": "looks_gotofrontback",
161
+ "fields": [("FRONT_BACK", "back")],
162
+ },
163
+ "goFront": {
164
+ "opcode": "looks_gotofrontback",
165
+ "fields": [("FRONT_BACK", "front")],
166
+ },
167
+ "goBack": {
168
+ "opcode": "looks_gotofrontback",
169
+ "fields": [("FRONT_BACK", "back")],
170
+ },
171
+ "goForwardLayers": {
156
172
  "opcode": "looks_goforwardbackwardlayers",
157
173
  "fields": [("FRONTBACK", "front")],
158
- "args": [("NUM", "number", "args", True)], # optional - default 1
174
+ "args": [("NUM", "number", "args")],
159
175
  },
160
- "goToBack": {
176
+ "goBackLayers": {
161
177
  "opcode": "looks_goforwardbackwardlayers",
162
178
  "fields": [("FRONTBACK", "back")],
163
- "args": [("NUM", "number", "args", True)],
179
+ "args": [("NUM", "number", "args")],
164
180
  },
165
- "goBackLayers": {
181
+ "goFwdLyrs": {
182
+ "opcode": "looks_goforwardbackwardlayers",
183
+ "fields": [("FRONTBACK", "front")],
184
+ "args": [("NUM", "number", "args")],
185
+ },
186
+ "goBwdLyrs": {
166
187
  "opcode": "looks_goforwardbackwardlayers",
167
188
  "fields": [("FRONTBACK", "back")],
168
189
  "args": [("NUM", "number", "args")],
169
190
  },
170
- "goForwardLayers": {
191
+ "goFwdLayers": {
171
192
  "opcode": "looks_goforwardbackwardlayers",
172
193
  "fields": [("FRONTBACK", "front")],
173
194
  "args": [("NUM", "number", "args")],
174
195
  },
196
+ "goBwdLayers": {
197
+ "opcode": "looks_goforwardbackwardlayers",
198
+ "fields": [("FRONTBACK", "back")],
199
+ "args": [("NUM", "number", "args")],
200
+ },
175
201
 
176
202
  # ---- Sound ----
177
203
  "playSound": {
@@ -552,8 +578,23 @@ REPORTERS = {
552
578
  # Looks
553
579
  "costumeName": {"opcode": "looks_costumename", "returns": "string"},
554
580
  "costumeNumber": {"opcode": "looks_costumenumber", "returns": "number"},
581
+ "costumeNumberName": {
582
+ "opcode": "looks_costumenumbername",
583
+ "fields": [("NUMBER_NAME", None)],
584
+ "returns": "any",
585
+ },
586
+ "cstmNumName": {
587
+ "opcode": "looks_costumenumbername",
588
+ "fields": [("NUMBER_NAME", None)],
589
+ "returns": "any",
590
+ },
555
591
  "backdropName": {"opcode": "looks_backdropname", "returns": "string"},
556
592
  "backdropNumber": {"opcode": "looks_backdropnumber", "returns": "number"},
593
+ "backdropNumberName": {
594
+ "opcode": "looks_backdropnumbername",
595
+ "fields": [("NUMBER_NAME", None)],
596
+ "returns": "any",
597
+ },
557
598
  "size": {"opcode": "looks_size", "returns": "number"},
558
599
 
559
600
  # Sound
@@ -0,0 +1,171 @@
1
+ """CLI entry point for TypeScratch.
2
+
3
+ Usage:
4
+ typescratch build <file.tysh> [--out <out.sb3>] [--debug]
5
+ typescratch run <file.tysh> [--debug]
6
+ typescratch ide [file.tysh]
7
+ typescratch decompile <file.sb3> [--out <file.tysh>]
8
+ typescratch fmt <file.tysh>
9
+ typescratch meow
10
+ typescratch version
11
+ typescratch help
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sys
18
+ from typing import List, Optional
19
+
20
+ from . import __version__, file as _file
21
+ from .errors import CompileError
22
+
23
+
24
+ def main(argv: Optional[List[str]] = None) -> int:
25
+ parser = argparse.ArgumentParser(
26
+ prog="typescratch",
27
+ description="Compile .tysh source files to Scratch 3 (.sb3) files.",
28
+ )
29
+ sub = parser.add_subparsers(dest="cmd", required=True)
30
+
31
+ p_build = sub.add_parser("build", help="Compile a .tysh file to .sb3")
32
+ p_build.add_argument("source", help="path to .tysh source file")
33
+ p_build.add_argument("--out", "-o", default=None, help="output .sb3 path")
34
+ p_build.add_argument("--debug", action="store_true", help="verbose debug output")
35
+
36
+ p_run = sub.add_parser("run", help="Alias for build")
37
+ p_run.add_argument("source")
38
+ p_run.add_argument("--out", "-o", default=None)
39
+ p_run.add_argument("--debug", action="store_true")
40
+
41
+ p_ide = sub.add_parser("ide", help="Launch the TypeScratch IDE")
42
+ p_ide.add_argument("source", nargs="?", default=None, help="optional .tysh file to open")
43
+
44
+ p_decompile = sub.add_parser("decompile", help="Decompile a .sb3 file to .tysh")
45
+ p_decompile.add_argument("source", help="path to .sb3 file")
46
+ p_decompile.add_argument("--out", "-o", default=None, help="output .tysh path")
47
+
48
+ p_fmt = sub.add_parser("fmt", help="Format a .tysh file (auto-indent)")
49
+ p_fmt.add_argument("source", help="path to .tysh file")
50
+ p_fmt.add_argument("--out", "-o", default=None, help="output path (default: in-place)")
51
+
52
+ sub.add_parser("meow", help="Print the Scratch Cat in ASCII art")
53
+
54
+ p_install = sub.add_parser("install", help="Install a package from GitHub")
55
+ p_install.add_argument("repo", help="github user/repo or user/repo@tag")
56
+
57
+ p_uninstall = sub.add_parser("uninstall", help="Remove an installed package")
58
+ p_uninstall.add_argument("name", help="package name")
59
+
60
+ sub.add_parser("list", help="List installed packages")
61
+
62
+ sub.add_parser("version", help="Print version")
63
+
64
+ args = parser.parse_args(argv)
65
+
66
+ if args.cmd == "version":
67
+ print(f"TypeScratch v{__version__}")
68
+ return 0
69
+
70
+ if args.cmd == "ide":
71
+ from .ide import run_ide
72
+ run_ide(args.source)
73
+ return 0
74
+
75
+ if args.cmd == "decompile":
76
+ from .decompiler import decompile_to_file
77
+ try:
78
+ out = decompile_to_file(args.source, args.out)
79
+ print(f"OK: decompiled to {out}", file=sys.stderr)
80
+ return 0
81
+ except Exception as e:
82
+ print(f"error: {e}", file=sys.stderr)
83
+ return 1
84
+
85
+ if args.cmd == "fmt":
86
+ return _format_file(args.source, args.out)
87
+
88
+ if args.cmd == "meow":
89
+ return _meow()
90
+
91
+ if args.cmd == "install":
92
+ from .package_manager import install
93
+ return install(args.repo)
94
+
95
+ if args.cmd == "uninstall":
96
+ from .package_manager import uninstall
97
+ return uninstall(args.name)
98
+
99
+ if args.cmd == "list":
100
+ from .package_manager import list_packages
101
+ return list_packages()
102
+
103
+ if args.cmd in ("build", "run"):
104
+ try:
105
+ out_path = _file(args.source, out=args.out, debug=args.debug)
106
+ print(f"OK: wrote {out_path}", file=sys.stderr)
107
+ return 0
108
+ except CompileError as e:
109
+ print(f"error: {e}", file=sys.stderr)
110
+ return 1
111
+ except FileNotFoundError as e:
112
+ print(f"error: {e}", file=sys.stderr)
113
+ return 2
114
+ except Exception as e:
115
+ print(f"internal error: {e}", file=sys.stderr)
116
+ import traceback
117
+ traceback.print_exc(file=sys.stderr)
118
+ return 3
119
+
120
+ parser.print_help()
121
+ return 1
122
+
123
+
124
+ def _format_file(path: str, out: Optional[str] = None) -> int:
125
+ """Format a .tysh file with proper indentation."""
126
+ try:
127
+ with open(path, "r", encoding="utf-8") as f:
128
+ lines = f.readlines()
129
+ except Exception as e:
130
+ print(f"error: {e}", file=sys.stderr)
131
+ return 1
132
+
133
+ formatted = []
134
+ indent = 0
135
+ for line in lines:
136
+ stripped = line.strip()
137
+ # Decrease indent for closing braces
138
+ if stripped.startswith("}"):
139
+ indent = max(0, indent - 1)
140
+ # Add the line with proper indent
141
+ if stripped:
142
+ formatted.append(" " * indent + stripped + "\n")
143
+ else:
144
+ formatted.append("\n")
145
+ # Increase indent after opening braces
146
+ # Count opening and closing braces on this line
147
+ opens = stripped.count("{") - stripped.count("}")
148
+ if opens > 0 and not stripped.startswith("}"):
149
+ indent += opens
150
+
151
+ out_path = out or path
152
+ with open(out_path, "w", encoding="utf-8") as f:
153
+ f.writelines(formatted)
154
+ print(f"OK: formatted {path}" + (f" -> {out_path}" if out else " (in-place)"), file=sys.stderr)
155
+ return 0
156
+
157
+
158
+ def _meow() -> int:
159
+ """Print the Scratch Cat in ASCII art."""
160
+ import os
161
+ meow_path = os.path.join(os.path.dirname(__file__), "meow.txt")
162
+ try:
163
+ with open(meow_path, "r") as f:
164
+ print(f.read())
165
+ except FileNotFoundError:
166
+ print("Meow! 🐱")
167
+ return 0
168
+
169
+
170
+ if __name__ == "__main__":
171
+ sys.exit(main())
@@ -476,7 +476,7 @@ class Codegen:
476
476
  "rotationCenterY": rcy,
477
477
  })
478
478
  else:
479
- # Sprite costume
479
+ # Sprite costume - use img= if specified
480
480
  path = target.img
481
481
  data, _ignored_md5ext, fmt, rcx, rcy = self._load_asset(path, is_stage=False)
482
482
  asset_id = _md5(data)
@@ -491,11 +491,73 @@ class Codegen:
491
491
  "rotationCenterX": rcx,
492
492
  "rotationCenterY": rcy,
493
493
  })
494
+ # Additional costumes from `costume name from "path.png"` imports
495
+ for cinfo in getattr(target, "_costume_imports", []):
496
+ cname = cinfo["name"]
497
+ cpath = cinfo["path"]
498
+ cdata, _cmd5, cfmt, crcx, crcy = self._load_asset(cpath, is_stage=False)
499
+ casset_id = _md5(cdata)
500
+ cmd5ext = f"{casset_id}.{cfmt}"
501
+ self.assets[cmd5ext] = cdata
502
+ costumes.append({
503
+ "assetId": casset_id,
504
+ "name": cname,
505
+ "bitmapResolution": 1 if cfmt == "svg" else 2,
506
+ "md5ext": cmd5ext,
507
+ "dataFormat": cfmt,
508
+ "rotationCenterX": crcx,
509
+ "rotationCenterY": crcy,
510
+ })
494
511
  return costumes
495
512
 
496
513
  def gen_sounds(self, target: A.Target) -> List[dict]:
497
- # No sound declaration syntax yet - emit an empty list
498
- return []
514
+ """Generate sound assets from `sound name from "path.wav"` imports."""
515
+ sounds = []
516
+ for sinfo in getattr(target, "_sound_imports", []):
517
+ name = sinfo["name"]
518
+ path = sinfo["path"]
519
+ full = path
520
+ if self.asset_root and not os.path.isabs(path):
521
+ full = os.path.join(self.asset_root, path)
522
+ if os.path.isfile(full):
523
+ with open(full, "rb") as f:
524
+ data = f.read()
525
+ # Determine format
526
+ if full.lower().endswith(".wav"):
527
+ fmt = "wav"
528
+ elif full.lower().endswith(".mp3"):
529
+ fmt = "mp3"
530
+ else:
531
+ fmt = "wav"
532
+ asset_id = _md5(data)
533
+ md5ext = f"{asset_id}.{fmt}"
534
+ self.assets[md5ext] = data
535
+ sounds.append({
536
+ "assetId": asset_id,
537
+ "name": name,
538
+ "dataFormat": fmt,
539
+ "format": "",
540
+ "rate": 48000,
541
+ "sampleCount": 0,
542
+ "md5ext": md5ext,
543
+ })
544
+ else:
545
+ # File not found - use silent fallback
546
+ from . import assets as ASSETS2
547
+ data = ASSETS2.SILENT_WAV
548
+ asset_id = _md5(data)
549
+ md5ext = f"{asset_id}.wav"
550
+ self.assets[md5ext] = data
551
+ sounds.append({
552
+ "assetId": asset_id,
553
+ "name": name,
554
+ "dataFormat": "wav",
555
+ "format": "",
556
+ "rate": 48000,
557
+ "sampleCount": 0,
558
+ "md5ext": md5ext,
559
+ })
560
+ return sounds
499
561
 
500
562
  def _load_asset(self, path: Optional[str], is_stage: bool) -> Tuple[bytes, str, str, int, int]:
501
563
  """Returns (data, md5ext, format, rotationCenterX, rotationCenterY)."""
@@ -676,6 +738,11 @@ class Codegen:
676
738
  parent_id, stmt.line)
677
739
  if isinstance(stmt, A.BlockCall):
678
740
  return self.gen_block_call(stmt, parent_id)
741
+ if isinstance(stmt, A.LocalVarDecl):
742
+ return self.gen_var_assign(
743
+ A.VarAssign(name=stmt.name, value=stmt.value, line=stmt.line),
744
+ parent_id
745
+ )
679
746
  raise CompileError(f"unknown statement type: {type(stmt).__name__}", stmt.line)
680
747
 
681
748
  def gen_simple_stack(self, opcode: str, inputs_spec, parent_id, line) -> Tuple[str, str]:
@@ -1485,6 +1552,8 @@ class Codegen:
1485
1552
  return None, block_id
1486
1553
  if isinstance(expr, A.ListReporterRef):
1487
1554
  return self._gen_list_reporter(expr)
1555
+ if isinstance(expr, A.TernaryExpr):
1556
+ return self._gen_ternary(expr)
1488
1557
  if isinstance(expr, A.BuiltinRef):
1489
1558
  # Builtin reporter (e.g. answer, mouseX)
1490
1559
  spec = B.REPORTERS.get(expr.name)
@@ -1544,6 +1613,63 @@ class Codegen:
1544
1613
  # default: string literal
1545
1614
  return A.StringLit(value=name, line=ref.line)
1546
1615
 
1616
+ def _gen_ternary(self, expr: A.TernaryExpr) -> Tuple[Optional[list], Optional[str]]:
1617
+ """Generate a ternary expression [if (cond) x else y].
1618
+
1619
+ This is desugared into: a temp variable, set by an if/else.
1620
+ We create the if/else blocks inline and return a VarRef to the temp.
1621
+ """
1622
+ # Create a temp variable
1623
+ tmp_name = f"_ternary_{gen_id()[:8]}"
1624
+ self._declare_var(tmp_name, 0)
1625
+ vid = self.current_vars[tmp_name]
1626
+
1627
+ # Coerce the condition
1628
+ cond = self._coerce_condition(expr.cond)
1629
+ shadow_cond, sub_cond = self.gen_input_value(cond, "boolean")
1630
+ cond_input = self._pack_input("boolean", shadow_cond, sub_cond)
1631
+
1632
+ # Generate the true branch: set tmp = true_expr
1633
+ true_shadow, true_sub = self.gen_input_value(expr.true_expr, "any")
1634
+ true_block_id = gen_id()
1635
+ self.current_blocks[true_block_id] = {
1636
+ "opcode": "data_setvariableto",
1637
+ "next": None, "parent": None,
1638
+ "inputs": {"VALUE": self._pack_input("any", true_shadow, true_sub)},
1639
+ "fields": {"VARIABLE": [tmp_name, vid]},
1640
+ "shadow": False, "topLevel": False,
1641
+ }
1642
+
1643
+ # Generate the false branch: set tmp = false_expr
1644
+ false_shadow, false_sub = self.gen_input_value(expr.false_expr, "any")
1645
+ false_block_id = gen_id()
1646
+ self.current_blocks[false_block_id] = {
1647
+ "opcode": "data_setvariableto",
1648
+ "next": None, "parent": None,
1649
+ "inputs": {"VALUE": self._pack_input("any", false_shadow, false_sub)},
1650
+ "fields": {"VARIABLE": [tmp_name, vid]},
1651
+ "shadow": False, "topLevel": False,
1652
+ }
1653
+
1654
+ # Generate the if/else block
1655
+ if_block_id = gen_id()
1656
+ self.current_blocks[if_block_id] = {
1657
+ "opcode": "control_if_else",
1658
+ "next": None, "parent": None,
1659
+ "inputs": {
1660
+ "CONDITION": cond_input,
1661
+ "SUBSTACK": [2, true_block_id],
1662
+ "SUBSTACK2": [2, false_block_id],
1663
+ },
1664
+ "fields": {},
1665
+ "shadow": False, "topLevel": False,
1666
+ }
1667
+ self.current_blocks[true_block_id]["parent"] = if_block_id
1668
+ self.current_blocks[false_block_id]["parent"] = if_block_id
1669
+
1670
+ # Return a variable reference to the temp
1671
+ return [SHADOW_VAR, tmp_name, vid], None
1672
+
1547
1673
  def _gen_list_reporter(self, expr: A.ListReporterRef) -> Tuple[Optional[list], Optional[str]]:
1548
1674
  """Generate a list reporter block (data_itemoflist, data_lengthoflist,
1549
1675
  data_listcontainsitem, data_itemnumoflist, data_listcontents).