quantum-framework 0.18.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.
Files changed (333) hide show
  1. quantum/__init__.py +1 -0
  2. quantum/cli/__init__.py +2 -0
  3. quantum/cli/admin.py +131 -0
  4. quantum/cli/check.py +223 -0
  5. quantum/cli/commands/__init__.py +15 -0
  6. quantum/cli/commands/build.py +414 -0
  7. quantum/cli/commands/dev.py +185 -0
  8. quantum/cli/commands/docs.py +329 -0
  9. quantum/cli/commands/lint.py +523 -0
  10. quantum/cli/commands/migrate.py +548 -0
  11. quantum/cli/commands/new.py +622 -0
  12. quantum/cli/commands/serve.py +190 -0
  13. quantum/cli/commands/test.py +193 -0
  14. quantum/cli/deploy.py +810 -0
  15. quantum/cli/hot_reload.py +951 -0
  16. quantum/cli/jobs.py +356 -0
  17. quantum/cli/mq.py +582 -0
  18. quantum/cli/pkg.py +390 -0
  19. quantum/cli/runner.py +565 -0
  20. quantum/cli/server_process.py +159 -0
  21. quantum/cli/utils.py +334 -0
  22. quantum/compiler/__init__.py +30 -0
  23. quantum/compiler/base_generator.py +367 -0
  24. quantum/compiler/cli.py +295 -0
  25. quantum/compiler/expression_transformer.py +444 -0
  26. quantum/compiler/javascript/__init__.py +10 -0
  27. quantum/compiler/javascript/generator.py +659 -0
  28. quantum/compiler/optimizer.py +270 -0
  29. quantum/compiler/python/__init__.py +10 -0
  30. quantum/compiler/python/generator.py +883 -0
  31. quantum/compiler/python/runtime.py +863 -0
  32. quantum/compiler/transpiler.py +330 -0
  33. quantum/core/__init__.py +3 -0
  34. quantum/core/ast_nodes.py +2619 -0
  35. quantum/core/config_env.py +54 -0
  36. quantum/core/docs_links.py +8 -0
  37. quantum/core/expression_diagnostics.py +87 -0
  38. quantum/core/expression_stdlib.py +208 -0
  39. quantum/core/expressions.py +677 -0
  40. quantum/core/features/agents/src/__init__.py +30 -0
  41. quantum/core/features/agents/src/ast_node.py +540 -0
  42. quantum/core/features/conditionals/src/__init__.py +8 -0
  43. quantum/core/features/conditionals/src/ast_node.py +68 -0
  44. quantum/core/features/data_import/src/__init__.py +0 -0
  45. quantum/core/features/data_import/src/ast_node.py +292 -0
  46. quantum/core/features/data_import/src/runtime.py +533 -0
  47. quantum/core/features/dump/src/__init__.py +12 -0
  48. quantum/core/features/dump/src/ast_node.py +106 -0
  49. quantum/core/features/dump/src/parser.py +61 -0
  50. quantum/core/features/dump/src/runtime.py +246 -0
  51. quantum/core/features/functions/src/__init__.py +8 -0
  52. quantum/core/features/functions/src/ast_node.py +149 -0
  53. quantum/core/features/game_engine_2d/src/__init__.py +19 -0
  54. quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
  55. quantum/core/features/game_engine_2d/src/parser.py +983 -0
  56. quantum/core/features/invocation/src/__init__.py +0 -0
  57. quantum/core/features/invocation/src/ast_node.py +147 -0
  58. quantum/core/features/invocation/src/runtime.py +332 -0
  59. quantum/core/features/knowledge_base/src/__init__.py +6 -0
  60. quantum/core/features/knowledge_base/src/ast_node.py +113 -0
  61. quantum/core/features/knowledge_base/src/parser.py +82 -0
  62. quantum/core/features/logging/src/__init__.py +12 -0
  63. quantum/core/features/logging/src/ast_node.py +111 -0
  64. quantum/core/features/logging/src/parser.py +50 -0
  65. quantum/core/features/logging/src/runtime.py +190 -0
  66. quantum/core/features/loops/src/__init__.py +8 -0
  67. quantum/core/features/loops/src/ast_node.py +60 -0
  68. quantum/core/features/query/src/__init__.py +0 -0
  69. quantum/core/features/query/src/query_validators.py +20 -0
  70. quantum/core/features/state_management/src/__init__.py +11 -0
  71. quantum/core/features/state_management/src/ast_node.py +121 -0
  72. quantum/core/features/terminal_engine/src/__init__.py +21 -0
  73. quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
  74. quantum/core/features/terminal_engine/src/parser.py +361 -0
  75. quantum/core/features/testing_engine/src/__init__.py +41 -0
  76. quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
  77. quantum/core/features/testing_engine/src/parser.py +604 -0
  78. quantum/core/features/theming/src/__init__.py +48 -0
  79. quantum/core/features/theming/src/ast_node.py +137 -0
  80. quantum/core/features/theming/src/presets.py +405 -0
  81. quantum/core/features/ui_engine/src/__init__.py +20 -0
  82. quantum/core/features/ui_engine/src/ast_nodes.py +1913 -0
  83. quantum/core/features/ui_engine/src/parser.py +1192 -0
  84. quantum/core/features/websocket/src/__init__.py +24 -0
  85. quantum/core/features/websocket/src/ast_node.py +247 -0
  86. quantum/core/guards.py +53 -0
  87. quantum/core/html_compat.py +299 -0
  88. quantum/core/parser.py +1551 -0
  89. quantum/core/parser_registry.py +213 -0
  90. quantum/core/parsers/__init__.py +76 -0
  91. quantum/core/parsers/ai/__init__.py +12 -0
  92. quantum/core/parsers/ai/agent_parser.py +106 -0
  93. quantum/core/parsers/ai/knowledge_parser.py +86 -0
  94. quantum/core/parsers/ai/llm_parser.py +78 -0
  95. quantum/core/parsers/ai/team_parser.py +88 -0
  96. quantum/core/parsers/base.py +322 -0
  97. quantum/core/parsers/composition/__init__.py +10 -0
  98. quantum/core/parsers/composition/import_parser.py +50 -0
  99. quantum/core/parsers/composition/slot_parser.py +48 -0
  100. quantum/core/parsers/control_flow/__init__.py +11 -0
  101. quantum/core/parsers/control_flow/if_parser.py +71 -0
  102. quantum/core/parsers/control_flow/loop_parser.py +105 -0
  103. quantum/core/parsers/control_flow/set_parser.py +93 -0
  104. quantum/core/parsers/data/__init__.py +12 -0
  105. quantum/core/parsers/data/data_parser.py +229 -0
  106. quantum/core/parsers/data/invoke_parser.py +133 -0
  107. quantum/core/parsers/data/query_parser.py +213 -0
  108. quantum/core/parsers/data/transaction_parser.py +86 -0
  109. quantum/core/parsers/events/__init__.py +9 -0
  110. quantum/core/parsers/events/dispatch_event_parser.py +44 -0
  111. quantum/core/parsers/forms/__init__.py +11 -0
  112. quantum/core/parsers/forms/action_parser.py +79 -0
  113. quantum/core/parsers/forms/flash_parser.py +34 -0
  114. quantum/core/parsers/forms/redirect_parser.py +33 -0
  115. quantum/core/parsers/functions/__init__.py +11 -0
  116. quantum/core/parsers/functions/function_parser.py +98 -0
  117. quantum/core/parsers/functions/param_parser.py +73 -0
  118. quantum/core/parsers/functions/return_parser.py +31 -0
  119. quantum/core/parsers/html/__init__.py +10 -0
  120. quantum/core/parsers/html/component_call_parser.py +130 -0
  121. quantum/core/parsers/html/html_parser.py +115 -0
  122. quantum/core/parsers/jobs/__init__.py +11 -0
  123. quantum/core/parsers/jobs/job_parser.py +71 -0
  124. quantum/core/parsers/jobs/schedule_parser.py +62 -0
  125. quantum/core/parsers/jobs/thread_parser.py +57 -0
  126. quantum/core/parsers/messaging/__init__.py +17 -0
  127. quantum/core/parsers/messaging/message_ack_parser.py +30 -0
  128. quantum/core/parsers/messaging/message_nack_parser.py +30 -0
  129. quantum/core/parsers/messaging/message_parser.py +114 -0
  130. quantum/core/parsers/messaging/queue_parser.py +61 -0
  131. quantum/core/parsers/messaging/websocket_parser.py +121 -0
  132. quantum/core/parsers/routing/__init__.py +9 -0
  133. quantum/core/parsers/routing/route_parser.py +41 -0
  134. quantum/core/parsers/scripting/__init__.py +12 -0
  135. quantum/core/parsers/scripting/pyclass_parser.py +62 -0
  136. quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
  137. quantum/core/parsers/scripting/pyimport_parser.py +52 -0
  138. quantum/core/parsers/scripting/python_parser.py +49 -0
  139. quantum/core/parsers/services/__init__.py +12 -0
  140. quantum/core/parsers/services/dump_parser.py +52 -0
  141. quantum/core/parsers/services/file_parser.py +48 -0
  142. quantum/core/parsers/services/log_parser.py +46 -0
  143. quantum/core/parsers/services/mail_parser.py +65 -0
  144. quantum/core/tiers.py +164 -0
  145. quantum/core/xml_lines.py +81 -0
  146. quantum/packages/__init__.py +28 -0
  147. quantum/packages/manager.py +413 -0
  148. quantum/packages/manifest.py +351 -0
  149. quantum/packages/registry.py +399 -0
  150. quantum/packages/resolver.py +336 -0
  151. quantum/plugins/__init__.py +33 -0
  152. quantum/plugins/hooks.py +329 -0
  153. quantum/plugins/loader.py +479 -0
  154. quantum/plugins/manifest.py +336 -0
  155. quantum/plugins/registry.py +371 -0
  156. quantum/runtime/__init__.py +25 -0
  157. quantum/runtime/action_handler.py +532 -0
  158. quantum/runtime/adapters/__init__.py +88 -0
  159. quantum/runtime/adapters/memory_adapter.py +690 -0
  160. quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
  161. quantum/runtime/adapters/redis_adapter.py +582 -0
  162. quantum/runtime/adapters/sqlite_adapter.py +414 -0
  163. quantum/runtime/agent_service.py +1133 -0
  164. quantum/runtime/ast_cache.py +506 -0
  165. quantum/runtime/auth_service.py +267 -0
  166. quantum/runtime/component.py +1048 -0
  167. quantum/runtime/component_composer.py +133 -0
  168. quantum/runtime/component_resolver.py +174 -0
  169. quantum/runtime/conditions.py +54 -0
  170. quantum/runtime/database_service.py +595 -0
  171. quantum/runtime/db_schema.py +100 -0
  172. quantum/runtime/dev_panel.py +178 -0
  173. quantum/runtime/email_service.py +162 -0
  174. quantum/runtime/error_handler.py +295 -0
  175. quantum/runtime/execution_context.py +283 -0
  176. quantum/runtime/executor_registry.py +178 -0
  177. quantum/runtime/executors/__init__.py +71 -0
  178. quantum/runtime/executors/ai/__init__.py +12 -0
  179. quantum/runtime/executors/ai/agent_executor.py +217 -0
  180. quantum/runtime/executors/ai/knowledge_executor.py +114 -0
  181. quantum/runtime/executors/ai/llm_executor.py +153 -0
  182. quantum/runtime/executors/ai/team_executor.py +171 -0
  183. quantum/runtime/executors/base.py +264 -0
  184. quantum/runtime/executors/control_flow/__init__.py +12 -0
  185. quantum/runtime/executors/control_flow/if_executor.py +98 -0
  186. quantum/runtime/executors/control_flow/loop_executor.py +308 -0
  187. quantum/runtime/executors/control_flow/redirect_executor.py +43 -0
  188. quantum/runtime/executors/control_flow/set_executor.py +464 -0
  189. quantum/runtime/executors/data/__init__.py +12 -0
  190. quantum/runtime/executors/data/data_executor.py +156 -0
  191. quantum/runtime/executors/data/invoke_executor.py +228 -0
  192. quantum/runtime/executors/data/query_executor.py +290 -0
  193. quantum/runtime/executors/data/transaction_executor.py +91 -0
  194. quantum/runtime/executors/jobs/__init__.py +11 -0
  195. quantum/runtime/executors/jobs/job_executor.py +190 -0
  196. quantum/runtime/executors/jobs/schedule_executor.py +132 -0
  197. quantum/runtime/executors/jobs/thread_executor.py +127 -0
  198. quantum/runtime/executors/messaging/__init__.py +17 -0
  199. quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
  200. quantum/runtime/executors/messaging/message_executor.py +174 -0
  201. quantum/runtime/executors/messaging/queue_executor.py +103 -0
  202. quantum/runtime/executors/messaging/websocket_executor.py +197 -0
  203. quantum/runtime/executors/scripting/__init__.py +11 -0
  204. quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
  205. quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
  206. quantum/runtime/executors/scripting/python_executor.py +249 -0
  207. quantum/runtime/executors/services/__init__.py +12 -0
  208. quantum/runtime/executors/services/dump_executor.py +72 -0
  209. quantum/runtime/executors/services/file_executor.py +89 -0
  210. quantum/runtime/executors/services/log_executor.py +77 -0
  211. quantum/runtime/executors/services/mail_executor.py +81 -0
  212. quantum/runtime/expression_cache.py +498 -0
  213. quantum/runtime/file_upload_service.py +326 -0
  214. quantum/runtime/function_registry.py +118 -0
  215. quantum/runtime/game_builder.py +166 -0
  216. quantum/runtime/game_code_generator.py +2371 -0
  217. quantum/runtime/game_templates.py +2006 -0
  218. quantum/runtime/godot_code_generator.py +4681 -0
  219. quantum/runtime/godot_templates.py +1449 -0
  220. quantum/runtime/job_executor.py +1599 -0
  221. quantum/runtime/knowledge_service.py +546 -0
  222. quantum/runtime/llm_cache.py +100 -0
  223. quantum/runtime/llm_providers.py +704 -0
  224. quantum/runtime/llm_service.py +287 -0
  225. quantum/runtime/local_server.py +28 -0
  226. quantum/runtime/logging_setup.py +141 -0
  227. quantum/runtime/message_broker.py +364 -0
  228. quantum/runtime/message_queue_service.py +571 -0
  229. quantum/runtime/param_validation.py +218 -0
  230. quantum/runtime/pypy_compat.py +317 -0
  231. quantum/runtime/python_bridge.py +698 -0
  232. quantum/runtime/query_validators.py +304 -0
  233. quantum/runtime/renderer.py +734 -0
  234. quantum/runtime/service_container.py +470 -0
  235. quantum/runtime/table_params.py +163 -0
  236. quantum/runtime/terminal_builder.py +76 -0
  237. quantum/runtime/terminal_code_generator.py +607 -0
  238. quantum/runtime/terminal_templates.py +243 -0
  239. quantum/runtime/testing_builder.py +77 -0
  240. quantum/runtime/testing_code_generator.py +833 -0
  241. quantum/runtime/testing_templates.py +85 -0
  242. quantum/runtime/ui_builder.py +222 -0
  243. quantum/runtime/ui_console.py +405 -0
  244. quantum/runtime/ui_desktop.py +56 -0
  245. quantum/runtime/ui_form_rules.py +193 -0
  246. quantum/runtime/ui_html_adapter.py +2567 -0
  247. quantum/runtime/ui_html_templates.py +2106 -0
  248. quantum/runtime/ui_mobile_adapter.py +1832 -0
  249. quantum/runtime/ui_mobile_templates.py +1003 -0
  250. quantum/runtime/ui_page_renderer.py +392 -0
  251. quantum/runtime/ui_pager.py +73 -0
  252. quantum/runtime/ui_table_edit.py +139 -0
  253. quantum/runtime/ui_textual_adapter.py +1866 -0
  254. quantum/runtime/ui_textual_templates.py +45 -0
  255. quantum/runtime/ui_tokens.py +465 -0
  256. quantum/runtime/ui_validator.py +365 -0
  257. quantum/runtime/ui_view.py +221 -0
  258. quantum/runtime/validators.py +256 -0
  259. quantum/runtime/web_server.py +2131 -0
  260. quantum/runtime/websocket_adapter.py +501 -0
  261. quantum/runtime/websocket_service.py +585 -0
  262. quantum/runtime/websocket_transport.py +289 -0
  263. quantum/runtime/wsgi.py +101 -0
  264. quantum/services.py +109 -0
  265. quantum/utils/__init__.py +1 -0
  266. quantum_admin/__init__.py +4 -0
  267. quantum_admin/assets/quantum-admin-connectors.js +123 -0
  268. quantum_admin/assets/quantum-admin.css +2058 -0
  269. quantum_admin/assets/quantum-source-highlight.js +106 -0
  270. quantum_admin/backend/__init__.py +4 -0
  271. quantum_admin/backend/audit_service.py +346 -0
  272. quantum_admin/backend/auth_service.py +394 -0
  273. quantum_admin/backend/component_discovery.py +766 -0
  274. quantum_admin/backend/config_generator.py +586 -0
  275. quantum_admin/backend/connector_service.py +1016 -0
  276. quantum_admin/backend/crud.py +557 -0
  277. quantum_admin/backend/database.py +226 -0
  278. quantum_admin/backend/db_setup_service.py +193 -0
  279. quantum_admin/backend/deploy_service.py +698 -0
  280. quantum_admin/backend/docker_service.py +422 -0
  281. quantum_admin/backend/environment_service.py +418 -0
  282. quantum_admin/backend/git_service.py +370 -0
  283. quantum_admin/backend/health_service.py +390 -0
  284. quantum_admin/backend/job_service.py +347 -0
  285. quantum_admin/backend/log_service.py +331 -0
  286. quantum_admin/backend/main.py +14426 -0
  287. quantum_admin/backend/models.py +986 -0
  288. quantum_admin/backend/pipeline_service.py +812 -0
  289. quantum_admin/backend/resource_manager.py +1118 -0
  290. quantum_admin/backend/schemas.py +419 -0
  291. quantum_admin/backend/secret_manager.py +277 -0
  292. quantum_admin/backend/settings_service.py +488 -0
  293. quantum_admin/backend/template_service.py +710 -0
  294. quantum_admin/backend/test_execution_service.py +364 -0
  295. quantum_admin/backend/test_generator.py +782 -0
  296. quantum_admin/backend/webhook_service.py +454 -0
  297. quantum_admin/backend/websocket_manager.py +360 -0
  298. quantum_admin/components/admin/_layout/AdminShell.q +121 -0
  299. quantum_admin/components/admin/agents.q +107 -0
  300. quantum_admin/components/admin/app/[name].q +558 -0
  301. quantum_admin/components/admin/applications.q +160 -0
  302. quantum_admin/components/admin/component/[...path].q +205 -0
  303. quantum_admin/components/admin/components.q +59 -0
  304. quantum_admin/components/admin/connectors.q +283 -0
  305. quantum_admin/components/admin/dashboard.q +86 -0
  306. quantum_admin/components/admin/database.q +95 -0
  307. quantum_admin/components/admin/features.q +69 -0
  308. quantum_admin/components/admin/index.q +12 -0
  309. quantum_admin/components/admin/jobs.q +75 -0
  310. quantum_admin/components/admin/login.q +65 -0
  311. quantum_admin/components/admin/logout.q +11 -0
  312. quantum_admin/components/admin/settings.q +174 -0
  313. quantum_admin/components/admin/source.q +45 -0
  314. quantum_admin/components/admin/tests.q +73 -0
  315. quantum_admin/run.py +13 -0
  316. quantum_admin/services/__init__.py +16 -0
  317. quantum_admin/services/_base.py +84 -0
  318. quantum_admin/services/_component_test_generator.py +205 -0
  319. quantum_admin/services/_yaml_editor.py +82 -0
  320. quantum_admin/services/apps.py +330 -0
  321. quantum_admin/services/auth.py +61 -0
  322. quantum_admin/services/components.py +210 -0
  323. quantum_admin/services/connectors.py +164 -0
  324. quantum_admin/services/projects.py +189 -0
  325. quantum_admin/services/repository.py +223 -0
  326. quantum_admin/services/settings.py +150 -0
  327. quantum_admin/services/yaml_import.py +118 -0
  328. quantum_framework-0.18.0.dist-info/METADATA +281 -0
  329. quantum_framework-0.18.0.dist-info/RECORD +333 -0
  330. quantum_framework-0.18.0.dist-info/WHEEL +5 -0
  331. quantum_framework-0.18.0.dist-info/entry_points.txt +2 -0
  332. quantum_framework-0.18.0.dist-info/licenses/LICENSE +21 -0
  333. quantum_framework-0.18.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,2371 @@
1
+ """
2
+ Game Engine 2D - Code Generator
3
+
4
+ Transforms Game AST nodes into JavaScript code that runs with PixiJS + Matter.js.
5
+ This is a compiler/transpiler: Python generates JS, the game runs 100% in the browser.
6
+
7
+ Uses JsBuilder for structured, safe JS generation - no fragile string concatenation.
8
+ """
9
+
10
+ import json
11
+ import re
12
+ from typing import List, Dict, Any, Optional
13
+
14
+ from quantum.core.features.game_engine_2d.src.ast_nodes import (
15
+ SceneNode, SpriteNode, PhysicsNode, ColliderNode, AnimationNode,
16
+ CameraNode, InputNode, SoundNode, ParticleNode, TimerNode,
17
+ SpawnNode, HudNode, TweenNode, TilemapNode, TilemapLayerNode,
18
+ BehaviorNode, UseNode, PrefabNode, InstanceNode, GroupNode,
19
+ StateMachineNode, StateNode, TransitionNode, RawCodeNode,
20
+ ClickableNode, EventNode, OnCollisionNode,
21
+ )
22
+ from quantum.core.ast_nodes import QuantumNode, HTMLNode, TextNode
23
+ from quantum.core.features.conditionals.src.ast_node import IfNode
24
+ from quantum.core.features.loops.src.ast_node import LoopNode
25
+ from quantum.core.features.state_management.src.ast_node import SetNode
26
+ from quantum.core.features.functions.src.ast_node import FunctionNode
27
+
28
+ from quantum.runtime.game_templates import (
29
+ HTML_TEMPLATE, PIXI_CDN, MATTER_CDN, HUD_DIV_TEMPLATE,
30
+ JsBuilder, js_string, js_id, js_number, js_bool, js_safe_value,
31
+ is_number, sanitize_hud_text, compile_binding_to_js,
32
+ emit_physics_sync, emit_tilemap_collision, emit_input_system,
33
+ emit_animation_system,
34
+ emit_easing_functions, emit_tween_system, emit_particle_system,
35
+ emit_audio_system, emit_timer_system,
36
+ emit_event_bus, emit_game_api, emit_spawn_system,
37
+ emit_mouse_system,
38
+ )
39
+
40
+
41
+ class GameCodeGenerator:
42
+ """Generates standalone HTML+JS from a game AST."""
43
+
44
+ def __init__(self):
45
+ self._sprites: List[Dict] = []
46
+ self._behaviors: Dict[str, BehaviorNode] = {}
47
+ self._prefabs: Dict[str, PrefabNode] = {}
48
+ self._state_vars: List[Dict] = [] # {name, value, type}
49
+ self._functions: List[FunctionNode] = []
50
+ self._sounds: List[Dict] = []
51
+ self._particles: List[Dict] = []
52
+ self._timers: List[Dict] = []
53
+ self._tweens: List[Dict] = []
54
+ self._huds: List[Dict] = []
55
+ self._custom_inputs: List[Dict] = []
56
+ self._events: List[Dict] = [] # qg:event declarations
57
+ self._camera: Optional[Dict] = None
58
+ self._physics: Optional[Dict] = None
59
+ self._assets: set = set()
60
+ self._sprite_counter: int = 0
61
+ self._scene_width: int = 800
62
+ self._scene_height: int = 600
63
+ self._spawners: List[Dict] = []
64
+ self._visual_tile_layers: List[Dict] = []
65
+ self._collision_grids: List[Dict] = []
66
+
67
+ # ------------------------------------------------------------------
68
+ # Public API
69
+ # ------------------------------------------------------------------
70
+
71
+ def generate(self, scene: SceneNode, behaviors: List[BehaviorNode] = None,
72
+ prefabs: List[PrefabNode] = None, title: str = "Quantum Game",
73
+ source_code: str = None) -> str:
74
+ """Generate full HTML from a SceneNode."""
75
+ self._scene_width = scene.width
76
+ self._scene_height = scene.height
77
+
78
+ for b in (behaviors or []):
79
+ self._behaviors[b.name] = b
80
+ for p in (prefabs or []):
81
+ self._prefabs[p.name] = p
82
+
83
+ self._process_scene_children(scene.children)
84
+
85
+ js = JsBuilder()
86
+ self._build_game_js(js, scene)
87
+ game_js = js.build()
88
+
89
+ hud_html = self._build_hud_html()
90
+
91
+ # Format source code with syntax highlighting
92
+ formatted_source = self._format_source_code(source_code) if source_code else "No source code available"
93
+
94
+ return HTML_TEMPLATE.format(
95
+ title=html_escape_title(title),
96
+ pixi_cdn=PIXI_CDN,
97
+ matter_cdn=MATTER_CDN,
98
+ hud_html=hud_html,
99
+ game_js=game_js,
100
+ source_code=formatted_source,
101
+ )
102
+
103
+ def _format_source_code(self, source: str) -> str:
104
+ """Format source code with HTML escaping only (clean display)."""
105
+ import html
106
+ # Just escape HTML entities - keep it simple and clean
107
+ return html.escape(source)
108
+
109
+ def _reset_scene_state(self):
110
+ """Reset per-scene state for multi-scene generation."""
111
+ self._sprites = []
112
+ self._state_vars = []
113
+ self._functions = []
114
+ self._sounds = []
115
+ self._particles = []
116
+ self._timers = []
117
+ self._tweens = []
118
+ self._huds = []
119
+ self._custom_inputs = []
120
+ self._events = []
121
+ self._camera = None
122
+ self._physics = None
123
+ self._sprite_counter = 0
124
+ self._spawners = []
125
+ self._visual_tile_layers = []
126
+ self._collision_grids = [] # Tilemap collision grids for AABB resolution
127
+
128
+ def generate_multi(self, scenes: List[SceneNode], initial: str,
129
+ behaviors: List[BehaviorNode] = None,
130
+ prefabs: List[PrefabNode] = None,
131
+ title: str = "Quantum Game",
132
+ source_code: str = None) -> str:
133
+ """Generate HTML with multiple scenes and scene switching."""
134
+ if not scenes:
135
+ return self.generate(SceneNode('empty'), behaviors, prefabs, title, source_code)
136
+
137
+ # Use initial/active scene dimensions for PIXI app
138
+ first = scenes[0]
139
+ for sc in scenes:
140
+ if sc.name == initial:
141
+ first = sc
142
+ break
143
+ self._scene_width = first.width
144
+ self._scene_height = first.height
145
+
146
+ for b in (behaviors or []):
147
+ self._behaviors[b.name] = b
148
+ for p in (prefabs or []):
149
+ self._prefabs[p.name] = p
150
+
151
+ # Collect all assets across all scenes
152
+ for scene in scenes:
153
+ self._reset_scene_state()
154
+ self._process_scene_children(scene.children)
155
+
156
+ # Now build the JS
157
+ js = JsBuilder()
158
+ js.line("(async function() {")
159
+ js.indent()
160
+
161
+ # PIXI App
162
+ js.section("PIXI APP")
163
+ js.const('app', 'new PIXI.Application()')
164
+ js.line(f"await app.init({{ width: {first.width}, height: {first.height}, background: {js_string(first.background)} }});")
165
+ js.line("document.body.appendChild(app.canvas);")
166
+
167
+ # Matter.js Engine
168
+ js.section("MATTER.JS ENGINE")
169
+ js.const('mEngine', 'Matter.Engine.create()')
170
+ js.assign('mEngine.gravity.x', '0')
171
+ js.assign('mEngine.gravity.y', '0')
172
+ js.const('mWorld', 'mEngine.world')
173
+
174
+ # Event Bus
175
+ js.section("EVENT BUS")
176
+ emit_event_bus(js)
177
+
178
+ # Shared state (merged from all scenes)
179
+ js.section("GAME STATE")
180
+ js.comment('State variables are initialized per-scene')
181
+
182
+ # Asset Loading (collect from all scenes)
183
+ all_assets = set()
184
+ for scene in scenes:
185
+ self._reset_scene_state()
186
+ self._process_scene_children(scene.children)
187
+ all_assets.update(self._assets)
188
+ js.section("ASSET LOADING")
189
+ if all_assets:
190
+ urls = sorted(all_assets)
191
+ asset_list = ', '.join(js_string(u) for u in urls)
192
+ js.line(f"await PIXI.Assets.load([{asset_list}]);")
193
+ else:
194
+ js.comment('No assets to preload')
195
+
196
+ # Animation System
197
+ js.section("ANIMATION SYSTEM")
198
+ emit_animation_system(js)
199
+
200
+ # Behaviors (shared)
201
+ js.section("BEHAVIORS")
202
+ self._emit_behaviors(js)
203
+
204
+ # Camera container
205
+ js.section("CAMERA")
206
+ js.const('_cameraContainer', 'new PIXI.Container()')
207
+ js.line("app.stage.addChild(_cameraContainer);")
208
+ js.func('updateCamera')
209
+ js.block_close()
210
+
211
+ # Shared sprite/body registries
212
+ js.section("SPRITES & BODIES")
213
+ js.const('_sprites', '{}')
214
+ js.const('_bodyToSprite', '{}')
215
+ emit_physics_sync(js)
216
+
217
+ # Game API
218
+ js.section("GAME API")
219
+ emit_game_api(js)
220
+
221
+ # Input
222
+ js.section("INPUT")
223
+ emit_input_system(js)
224
+ js.const('_controlledSprites', '{}')
225
+
226
+ # Particle system
227
+ js.section("PARTICLES")
228
+ emit_particle_system(js)
229
+
230
+ # Timer system
231
+ js.section("TIMERS")
232
+ emit_timer_system(js)
233
+
234
+ # Tween system
235
+ js.section("TWEENS")
236
+ emit_tween_system(js)
237
+
238
+ # Sound system
239
+ js.section("SOUNDS")
240
+ emit_audio_system(js)
241
+
242
+ # Teardown function
243
+ js.section("MULTI-SCENE")
244
+ js.func('_teardownScene')
245
+ js.line('_cameraContainer.removeChildren();')
246
+ js.line('Matter.Composite.clear(mWorld, false);')
247
+ js.line("for (const id in _sprites) delete _sprites[id];")
248
+ js.line("for (const id in _bodyToSprite) delete _bodyToSprite[id];")
249
+ js.line("for (const id in _controlledSprites) delete _controlledSprites[id];")
250
+ js.block_close()
251
+ js.blank()
252
+
253
+ js.const('_sceneInits', '{}')
254
+ js.blank()
255
+
256
+ # Generate init function per scene
257
+ for scene in scenes:
258
+ self._reset_scene_state()
259
+ self._process_scene_children(scene.children)
260
+ scene_name = js_id(scene.name)
261
+
262
+ js.line(f"_sceneInits[{js_string(scene.name)}] = async function() {{")
263
+ js.indent()
264
+
265
+ # Physics config for this scene
266
+ if self._physics:
267
+ js.assign('mEngine.gravity.x', js_number(self._physics['gravity_x']))
268
+ js.assign('mEngine.gravity.y', js_number(self._physics['gravity_y']))
269
+
270
+ # State vars
271
+ for sv in self._state_vars:
272
+ name = js_id(sv['name'])
273
+ val = js_safe_value(sv['value'], sv['type'])
274
+ js.line(f"var {name} = {val};")
275
+
276
+ # Sprites & Bodies
277
+ body_vars = []
278
+ for info in self._sprites:
279
+ sid = js_id(info['id'])
280
+ src = info['src']
281
+ x, y = js_number(info['x']), js_number(info['y'])
282
+ spr_var = f'_spr_{sid}'
283
+
284
+ if src:
285
+ js.const(spr_var, f"PIXI.Sprite.from({js_string(src)})")
286
+ else:
287
+ js.const(spr_var, 'new PIXI.Graphics()')
288
+ if info.get('width') and info.get('height'):
289
+ w, h = info['width'], info['height']
290
+ js.line(f"{spr_var}.rect(-{w/2}, -{h/2}, {w}, {h});")
291
+ # Use color if provided, otherwise default to a visible color
292
+ color = info.get('color')
293
+ if color:
294
+ hex_color = color.lstrip('#')
295
+ js.line(f"{spr_var}.fill({{ color: 0x{hex_color} }});")
296
+ else:
297
+ js.line(f"{spr_var}.fill({{ color: 0x4a9eff }});")
298
+
299
+ js.assign(f'{spr_var}.x', x)
300
+ js.assign(f'{spr_var}.y', y)
301
+ if src:
302
+ js.line(f"{spr_var}.anchor.set({js_number(info['anchor_x'])}, {js_number(info['anchor_y'])});")
303
+ # Only set width/height if NOT using frame-based animations
304
+ # (animations will handle sizing via frame textures)
305
+ has_frame_anims = info.get('frame_width') and info.get('frame_height')
306
+ if info.get('width') and src and not has_frame_anims:
307
+ js.assign(f'{spr_var}.width', js_number(info['width']))
308
+ if info.get('height') and src and not has_frame_anims:
309
+ js.assign(f'{spr_var}.height', js_number(info['height']))
310
+ js.line(f"_cameraContainer.addChild({spr_var});")
311
+
312
+ body_var = self._emit_body(js, info, sid)
313
+ if body_var:
314
+ body_vars.append(body_var)
315
+ js.assign(f'_bodyToSprite[{body_var}.id]', js_string(info['id']))
316
+
317
+ # Clickable setup (multi-scene)
318
+ if info.get('clickable'):
319
+ cursor = info['clickable'].get('cursor', 'pointer')
320
+ action = js_id(info['clickable']['action'])
321
+ js.assign(f"{spr_var}.eventMode", "'static'")
322
+ js.assign(f"{spr_var}.cursor", js_string(cursor))
323
+ sid_str_click = js_string(info['id'])
324
+ js.line(f"{spr_var}.on('pointerdown', (e) => {{")
325
+ js.indent()
326
+ js.const('_info', f'_sprites[{sid_str_click}]')
327
+ js.let('_handled', 'false')
328
+ js.if_block('_info && _info.behaviors')
329
+ js.for_of('_b', '_info.behaviors')
330
+ js.if_block(f"typeof _b.{action} === 'function'")
331
+ js.line(f"_b.{action}(e);")
332
+ js.assign('_handled', 'true')
333
+ js.block_close()
334
+ js.block_close()
335
+ js.block_close()
336
+ js.if_block(f"!_handled && typeof {action} === 'function'")
337
+ js.line(f"{action}(_info, e);")
338
+ js.block_close()
339
+ js.dedent()
340
+ js.line("});")
341
+
342
+ body_ref = f'_body_{sid}' if info.get('body') else 'null'
343
+ tag_ref = js_string(info['tag']) if info.get('tag') else 'null'
344
+ id_ref = js_string(info['id'])
345
+ js.line(f"_sprites[{id_ref}] = {{ id: {id_ref}, sprite: {spr_var}, body: {body_ref}, tag: {tag_ref}, collisionHandlers: [], behaviors: [] }};")
346
+
347
+ if body_vars:
348
+ js.line(f"Matter.Composite.add(mWorld, [{', '.join(body_vars)}]);")
349
+
350
+ # Behavior attachments for this scene
351
+ self._emit_behavior_attachments(js)
352
+
353
+ # Semantic behaviors for this scene
354
+ self._emit_semantic_behaviors(js)
355
+
356
+ # Animations
357
+ self._emit_animation_registration(js)
358
+
359
+ # Controlled sprites
360
+ for info in self._sprites:
361
+ if info.get('controls'):
362
+ ctrl = info['controls']
363
+ sid_str = js_string(info['id'])
364
+ if ctrl == 'wasd':
365
+ js.assign(f'_controlledSprites[{sid_str}]',
366
+ "{ left: 'a', right: 'd', up: 'w', jump: ' ' }")
367
+ elif ctrl == 'arrows':
368
+ js.assign(f'_controlledSprites[{sid_str}]',
369
+ "{ left: 'ArrowLeft', right: 'ArrowRight', up: 'ArrowUp', jump: 'ArrowUp' }")
370
+
371
+ # Camera
372
+ if self._camera and self._camera.get('follow'):
373
+ target = js_string(self._camera['follow'])
374
+ lerp = js_number(self._camera['lerp'])
375
+ js.assign('updateCamera', f"function() {{ const info = _sprites[{target}]; if (!info) return; const tx = app.screen.width / 2 - info.sprite.x; const ty = app.screen.height / 2 - info.sprite.y; _cameraContainer.x += (tx - _cameraContainer.x) * {lerp}; _cameraContainer.y += (ty - _cameraContainer.y) * {lerp}; }}")
376
+
377
+ # Functions
378
+ for fn in self._functions:
379
+ params = ', '.join(js_id(p.name) for p in fn.params)
380
+ js.func(js_id(fn.name), params)
381
+ for stmt in fn.body:
382
+ self._emit_statement(js, stmt)
383
+ js.block_close()
384
+
385
+ # Collision handler
386
+ self._emit_collision_handler(js)
387
+
388
+ # Sounds
389
+ for s in self._sounds:
390
+ js.line(f"await _loadSound({js_string(s['id'])}, {js_string(s['src'])});")
391
+
392
+ # Particle instances
393
+ self._emit_particle_instances(js)
394
+
395
+ # Timer instances
396
+ self._emit_timer_instances(js)
397
+
398
+ # Tween instances
399
+ self._emit_tween_instances(js)
400
+
401
+ # Scene start triggers
402
+ self._emit_scene_start(js)
403
+
404
+ js.dedent()
405
+ js.line("};")
406
+ js.blank()
407
+
408
+ # Load scene function
409
+ js.func('_loadScene', 'name')
410
+ js.line('_teardownScene();')
411
+ js.if_block('_sceneInits[name]')
412
+ js.line('_sceneInits[name]();')
413
+ js.block_close()
414
+ js.block_close()
415
+ js.blank()
416
+
417
+ # HUD update placeholder
418
+ js.section("HUD UPDATE")
419
+ js.func('_updateHUD')
420
+ js.block_close()
421
+
422
+ # Game Loop
423
+ js.section("GAME LOOP")
424
+ self._emit_game_loop_multi(js)
425
+
426
+ # Initial scene load
427
+ js.line(f"_loadScene({js_string(initial)});")
428
+
429
+ js.dedent()
430
+ js.line("})();")
431
+
432
+ game_js = js.build()
433
+
434
+ # Collect all HUD HTML from all scenes
435
+ all_hud_html_parts = []
436
+ for scene in scenes:
437
+ self._reset_scene_state()
438
+ self._process_scene_children(scene.children)
439
+ hud_html = self._build_hud_html()
440
+ if hud_html:
441
+ all_hud_html_parts.append(hud_html)
442
+
443
+ # Format source code with syntax highlighting
444
+ formatted_source = self._format_source_code(source_code) if source_code else "No source code available"
445
+
446
+ return HTML_TEMPLATE.format(
447
+ title=html_escape_title(title),
448
+ pixi_cdn=PIXI_CDN,
449
+ matter_cdn=MATTER_CDN,
450
+ hud_html='\n'.join(all_hud_html_parts),
451
+ game_js=game_js,
452
+ source_code=formatted_source,
453
+ )
454
+
455
+ def _emit_game_loop_multi(self, js: JsBuilder):
456
+ """Game loop for multi-scene mode."""
457
+ js.line("app.ticker.add((ticker) => {")
458
+ js.indent()
459
+ js.const('dt', 'ticker.deltaTime')
460
+ js.comment('Sub-step physics with velocity clamping to prevent tunneling')
461
+ js.const('_fixedDt', '16.67')
462
+ js.const('_maxSteps', '3')
463
+ js.const('_maxVel', '12')
464
+ js.const('_steps', 'Math.min(Math.ceil(dt), _maxSteps)')
465
+ js.for_range('_i', '0', '_steps')
466
+ js.for_entries('_sid', '_sinfo', '_sprites')
467
+ js.if_block('_sinfo.body && !_sinfo.body.isStatic')
468
+ js.const('_bv', '_sinfo.body.velocity')
469
+ js.if_block('Math.abs(_bv.x) > _maxVel || Math.abs(_bv.y) > _maxVel')
470
+ js.line('Matter.Body.setVelocity(_sinfo.body, { x: Math.max(-_maxVel, Math.min(_maxVel, _bv.x)), y: Math.max(-_maxVel, Math.min(_maxVel, _bv.y)) });')
471
+ js.block_close()
472
+ js.block_close()
473
+ js.block_close()
474
+ js.line('Matter.Engine.update(mEngine, _fixedDt);')
475
+ js.block_close()
476
+ js.line('syncPhysics();')
477
+ js.comment('Input handling')
478
+ js.for_entries('_sid', '_ctrl', '_controlledSprites')
479
+ js.const('_body', '_sprites[_sid] && _sprites[_sid].body')
480
+ js.if_block('!_body')
481
+ js.line('continue;')
482
+ js.block_close()
483
+ js.if_block('_keys[_ctrl.left]')
484
+ js.line("Matter.Body.setVelocity(_body, { x: -5, y: _body.velocity.y });")
485
+ js.block_close()
486
+ js.if_block('_keys[_ctrl.right]')
487
+ js.line("Matter.Body.setVelocity(_body, { x: 5, y: _body.velocity.y });")
488
+ js.block_close()
489
+ js.if_block('_justPressed[_ctrl.jump]')
490
+ js.line("Matter.Body.setVelocity(_body, { x: _body.velocity.x, y: -10 });")
491
+ js.line("_gameEvents.emit('player.jump', _sprites[_sid]);")
492
+ js.block_close()
493
+ js.if_block('!_keys[_ctrl.left] && !_keys[_ctrl.right]')
494
+ js.line("Matter.Body.setVelocity(_body, { x: _body.velocity.x * 0.8, y: _body.velocity.y });")
495
+ js.block_close()
496
+ js.block_close()
497
+ js.line('_clearJustPressed();')
498
+ js.line('updateCamera();')
499
+ js.line('_updateControlAnimations();')
500
+ js.line('_updateAnimatedSprites(ticker);')
501
+ js.comment('Behaviors update')
502
+ js.for_entries('_id', '_info', '_sprites')
503
+ js.if_block('_info.behaviors')
504
+ js.for_of('b', '_info.behaviors')
505
+ js.if_block('b._smUpdate')
506
+ js.line('b._smUpdate();')
507
+ js.block_close()
508
+ js.if_block('b.update')
509
+ js.line('b.update();')
510
+ js.block_close()
511
+ js.block_close()
512
+ js.block_close()
513
+ js.block_close()
514
+ js.line('_updateTimers(dt);')
515
+ js.line('_updateTweens(dt);')
516
+ js.line('_updateParticles(dt);')
517
+ js.line('_updateHUD();')
518
+ js.dedent()
519
+ js.line("});")
520
+
521
+ # ------------------------------------------------------------------
522
+ # Scene children processing
523
+ # ------------------------------------------------------------------
524
+
525
+ def _process_scene_children(self, children: List[QuantumNode]):
526
+ for child in children:
527
+ if isinstance(child, PhysicsNode):
528
+ self._physics = {
529
+ 'gravity_x': child.gravity_x,
530
+ 'gravity_y': child.gravity_y,
531
+ 'bounds': child.bounds,
532
+ 'debug': child.debug,
533
+ }
534
+ elif isinstance(child, CameraNode):
535
+ self._camera = {
536
+ 'follow': child.follow,
537
+ 'lerp': child.lerp,
538
+ 'bounds': child.bounds or 'none',
539
+ }
540
+ elif isinstance(child, SetNode):
541
+ self._state_vars.append({
542
+ 'name': child.name,
543
+ 'value': child.value if child.value is not None else (child.default if child.default is not None else ''),
544
+ 'type': child.type or 'string',
545
+ })
546
+ elif isinstance(child, FunctionNode):
547
+ self._functions.append(child)
548
+ elif isinstance(child, PrefabNode):
549
+ # Register prefab for later instantiation
550
+ self._prefabs[child.name] = child
551
+ elif isinstance(child, SpriteNode):
552
+ self._process_sprite(child)
553
+ elif isinstance(child, InstanceNode):
554
+ self._process_instance(child)
555
+ elif isinstance(child, GroupNode):
556
+ self._process_group(child)
557
+ elif isinstance(child, SoundNode):
558
+ self._sounds.append({
559
+ 'id': child.sound_id, 'src': child.src,
560
+ 'volume': child.volume, 'loop': child.loop,
561
+ 'trigger': child.trigger, 'channel': child.channel,
562
+ })
563
+ if child.src:
564
+ self._assets.add(child.src)
565
+ elif isinstance(child, ParticleNode):
566
+ self._particles.append({
567
+ 'id': child.particle_id, 'src': child.src,
568
+ 'follow': child.follow, 'trigger': child.trigger,
569
+ 'count': child.count, 'emit_rate': child.emit_rate,
570
+ 'lifetime': child.lifetime,
571
+ 'speed_min': child.speed_min, 'speed_max': child.speed_max,
572
+ 'angle_min': child.angle_min, 'angle_max': child.angle_max,
573
+ 'alpha_start': child.alpha_start, 'alpha_end': child.alpha_end,
574
+ })
575
+ elif isinstance(child, TimerNode):
576
+ self._timers.append({
577
+ 'id': child.timer_id, 'interval': child.interval,
578
+ 'repeat': child.repeat, 'auto_start': child.auto_start,
579
+ 'action': child.action,
580
+ })
581
+ elif isinstance(child, TweenNode):
582
+ self._tweens.append({
583
+ 'id': child.tween_id, 'target': child.target,
584
+ 'property': child.property, 'to': child.to_value,
585
+ 'duration': child.duration, 'easing': child.easing,
586
+ 'loop': child.loop, 'yoyo': child.yoyo,
587
+ 'delay': child.delay, 'auto_start': child.auto_start,
588
+ })
589
+ elif isinstance(child, HudNode):
590
+ self._huds.append({
591
+ 'position': child.position,
592
+ 'children': child.children,
593
+ })
594
+ elif isinstance(child, InputNode):
595
+ self._custom_inputs.append({
596
+ 'key': child.key, 'action': child.action,
597
+ 'type': child.input_type,
598
+ })
599
+ elif isinstance(child, SpawnNode):
600
+ self._spawners.append({
601
+ 'id': child.spawn_id,
602
+ 'prefab': child.prefab,
603
+ 'count': child.count,
604
+ 'interval': child.interval,
605
+ 'x': child.x,
606
+ 'y': child.y,
607
+ 'pool_size': child.pool_size,
608
+ })
609
+ elif isinstance(child, TilemapNode):
610
+ self._process_tilemap(child)
611
+ elif isinstance(child, EventNode):
612
+ self._events.append({
613
+ 'name': child.name,
614
+ 'handler': child.handler,
615
+ 'filter_tag': child.filter_tag,
616
+ 'filter_id': child.filter_id,
617
+ 'scope': child.scope,
618
+ })
619
+
620
+ # ------------------------------------------------------------------
621
+ # Sprite processing (collect info, no JS emitted yet)
622
+ # ------------------------------------------------------------------
623
+
624
+ def _process_sprite(self, sprite: SpriteNode, group_name: str = None):
625
+ info = self._extract_sprite_info(sprite)
626
+ info['group'] = group_name
627
+ if sprite.src:
628
+ self._assets.add(sprite.src)
629
+ self._sprites.append(info)
630
+
631
+ def _extract_sprite_info(self, sprite: SpriteNode) -> Dict[str, Any]:
632
+ info: Dict[str, Any] = {
633
+ 'id': sprite.sprite_id,
634
+ 'src': sprite.src,
635
+ 'x': sprite.x, 'y': sprite.y,
636
+ 'width': sprite.width, 'height': sprite.height,
637
+ 'anchor_x': sprite.anchor_x, 'anchor_y': sprite.anchor_y,
638
+ 'rotation': sprite.rotation,
639
+ 'scale_x': sprite.scale_x, 'scale_y': sprite.scale_y,
640
+ 'alpha': sprite.alpha,
641
+ 'visible': sprite.visible,
642
+ 'color': sprite.color,
643
+ 'tag': sprite.tag,
644
+ 'layer': sprite.layer,
645
+ 'body': sprite.body,
646
+ 'shape': sprite.shape, # box, circle, slope-left, slope-right
647
+ 'bounce': sprite.bounce, 'friction': sprite.friction,
648
+ 'mass': sprite.mass, 'sensor': sprite.sensor,
649
+ 'controls': sprite.controls,
650
+ 'speed': sprite.speed, 'jump_force': sprite.jump_force,
651
+ # SMW-style jump physics
652
+ 'gravity_up': sprite.gravity_up,
653
+ 'gravity_down': sprite.gravity_down,
654
+ 'jump_hold_boost': sprite.jump_hold_boost,
655
+ 'coyote_frames': sprite.coyote_frames,
656
+ 'max_fall_speed': sprite.max_fall_speed,
657
+ 'frame_width': sprite.frame_width, 'frame_height': sprite.frame_height,
658
+ 'group': None,
659
+ 'animations': [],
660
+ 'collider': None,
661
+ 'behaviors': [],
662
+ 'clickable': None,
663
+ 'on_collisions': [], # qg:on-collision handlers
664
+ }
665
+ for child in sprite.children:
666
+ if isinstance(child, AnimationNode):
667
+ info['animations'].append({
668
+ 'name': child.name, 'frames': child.frames,
669
+ 'speed': child.speed, 'loop': child.loop,
670
+ 'auto_play': child.auto_play,
671
+ })
672
+ elif isinstance(child, ColliderNode):
673
+ info['collider'] = {
674
+ 'shape': child.shape,
675
+ 'width': child.width, 'height': child.height,
676
+ 'radius': child.radius,
677
+ }
678
+ elif isinstance(child, UseNode):
679
+ info['behaviors'].append({
680
+ 'name': child.behavior,
681
+ 'overrides': child.overrides,
682
+ 'on_collision': child.on_collision,
683
+ 'extra_collisions': getattr(child, 'extra_collisions', []),
684
+ 'collision_tag': child.collision_tag,
685
+ })
686
+ elif isinstance(child, ClickableNode):
687
+ info['clickable'] = {
688
+ 'action': child.action,
689
+ 'cursor': child.cursor,
690
+ }
691
+ elif isinstance(child, OnCollisionNode):
692
+ info['on_collisions'].append({
693
+ 'with_tag': child.with_tag,
694
+ 'with_id': child.with_id,
695
+ 'action': child.action,
696
+ })
697
+ return info
698
+
699
+ def _process_instance(self, instance: InstanceNode):
700
+ prefab = self._prefabs.get(instance.prefab)
701
+ if not prefab:
702
+ return
703
+ for child in prefab.children:
704
+ if isinstance(child, SpriteNode):
705
+ sid = instance.instance_id or f"{instance.prefab}_{self._sprite_counter}"
706
+ self._sprite_counter += 1
707
+ info = self._extract_sprite_info(child)
708
+ info['id'] = sid
709
+ info['x'] = instance.x
710
+ info['y'] = instance.y
711
+ # Apply instance overrides to behavior overrides
712
+ for buse in info['behaviors']:
713
+ buse['overrides'] = dict(buse['overrides'])
714
+ buse['overrides'].update(instance.overrides)
715
+ if child.src:
716
+ self._assets.add(child.src)
717
+ # Collect non-None semantic attributes from prefab
718
+ sem = {}
719
+ for attr in ('entity_type', 'health', 'defeated_by', 'on_defeat',
720
+ 'contact_damage', 'knockback', 'i_frames', 'stomp_bounce',
721
+ 'movement', 'move_speed', 'turn_on_edge', 'facing',
722
+ 'reward', 'reward_kill', 'respawns', 'respawn_delay',
723
+ 'effect', 'duration', 'auto_move', 'collectible', 'carryable',
724
+ 'content', 'hits', 'breakable', 'invisible'):
725
+ val = getattr(prefab, attr, None)
726
+ if val is not None:
727
+ sem[attr] = val
728
+ if sem:
729
+ info['_prefab_semantics'] = sem
730
+ self._sprites.append(info)
731
+
732
+ def _process_group(self, group: GroupNode):
733
+ group_uses = []
734
+ for child in group.children:
735
+ if isinstance(child, SpriteNode):
736
+ self._process_sprite(child, group_name=group.name)
737
+ elif isinstance(child, InstanceNode):
738
+ self._process_instance(child)
739
+ elif isinstance(child, UseNode):
740
+ group_uses.append(child)
741
+ if group_uses:
742
+ for sprite_info in self._sprites:
743
+ if sprite_info.get('group') == group.name:
744
+ for use in group_uses:
745
+ sprite_info['behaviors'].append({
746
+ 'name': use.behavior,
747
+ 'overrides': use.overrides,
748
+ 'on_collision': use.on_collision,
749
+ 'extra_collisions': getattr(use, 'extra_collisions', []),
750
+ 'collision_tag': use.collision_tag,
751
+ })
752
+
753
+ def _process_tilemap(self, tilemap: TilemapNode):
754
+ if tilemap.src:
755
+ self._assets.add(tilemap.src)
756
+
757
+ # Convert tile animations to dict for easy lookup
758
+ tile_anims = {}
759
+ for anim in tilemap.tile_animations:
760
+ tile_anims[anim.tile_id] = {
761
+ 'frames': anim.frames,
762
+ 'speed': anim.speed,
763
+ }
764
+
765
+ for layer in tilemap.layers:
766
+ # Always render tiles visually (if tilemap has a src)
767
+ if tilemap.src:
768
+ self._visual_tile_layers.append({
769
+ 'tilemap_id': tilemap.tilemap_id,
770
+ 'layer_name': layer.name,
771
+ 'data': layer.data,
772
+ 'tile_width': tilemap.tile_width,
773
+ 'tile_height': tilemap.tile_height,
774
+ 'src': tilemap.src,
775
+ 'tile_animations': tile_anims, # Dict of tile_id -> {frames, speed}
776
+ })
777
+
778
+ # Collision layers use custom AABB-based grid collision instead of
779
+ # individual Matter.js bodies. This avoids edge-catching issues and
780
+ # supports slopes natively. Tile values: 0=empty, 1+=solid
781
+ if layer.collision:
782
+ rows = layer.data.strip().split('\n')
783
+ tw = tilemap.tile_width
784
+ th = tilemap.tile_height
785
+ grid = []
786
+ for row_str in rows:
787
+ cells = [int(c.strip()) if c.strip() else 0 for c in row_str.split(',')]
788
+ grid.append(cells)
789
+ self._collision_grids.append({
790
+ 'tilemap_id': tilemap.tilemap_id,
791
+ 'grid': grid,
792
+ 'tw': tw,
793
+ 'th': th,
794
+ })
795
+
796
+ # ------------------------------------------------------------------
797
+ # Main JS builder
798
+ # ------------------------------------------------------------------
799
+
800
+ def _build_game_js(self, js: JsBuilder, scene: SceneNode):
801
+ js.line("(async function() {")
802
+ js.indent()
803
+
804
+ # PIXI App - use viewport size for canvas, not world size
805
+ js.section("PIXI APP")
806
+ js.const('app', 'new PIXI.Application()')
807
+ # Use explicit viewport size if specified, otherwise cap to reasonable size
808
+ if scene.viewport_width and scene.viewport_height:
809
+ viewport_w = scene.viewport_width
810
+ viewport_h = scene.viewport_height
811
+ else:
812
+ viewport_w = min(scene.width, 800)
813
+ viewport_h = min(scene.height, 600)
814
+ self._viewport_width = viewport_w
815
+ self._viewport_height = viewport_h
816
+ js.line(f"await app.init({{ width: {viewport_w}, height: {viewport_h}, background: {js_string(scene.background)} }});")
817
+ js.line("document.body.appendChild(app.canvas);")
818
+ js.comment(f"World size: {scene.width}x{scene.height}, Viewport: {viewport_w}x{viewport_h}")
819
+
820
+ # Matter.js Engine
821
+ js.section("MATTER.JS ENGINE")
822
+ self._emit_physics_init(js, scene)
823
+
824
+ # Event Bus
825
+ js.section("EVENT BUS")
826
+ emit_event_bus(js)
827
+
828
+ # Game State
829
+ js.section("GAME STATE")
830
+ self._emit_state_vars(js)
831
+
832
+ # Asset Loading
833
+ js.section("ASSET LOADING")
834
+ self._emit_asset_loading(js)
835
+
836
+ # Animation System
837
+ js.section("ANIMATION SYSTEM")
838
+ emit_animation_system(js)
839
+
840
+ # Behaviors
841
+ js.section("BEHAVIORS")
842
+ self._emit_behaviors(js)
843
+
844
+ # Camera container
845
+ js.section("CAMERA")
846
+ self._emit_camera_setup(js)
847
+
848
+ # Visual Tilemaps (before sprites so sprites render on top)
849
+ js.section("VISUAL TILEMAPS")
850
+ self._emit_visual_tilemaps(js)
851
+
852
+ # Sprites & Bodies
853
+ js.section("SPRITES & BODIES")
854
+ self._emit_sprites(js)
855
+
856
+ # Tilemap Collision (grid-based, replaces per-tile physics bodies)
857
+ js.section("TILEMAP COLLISION")
858
+ emit_tilemap_collision(js)
859
+ self._emit_collision_grids(js)
860
+
861
+ # Game API
862
+ js.section("GAME API")
863
+ emit_game_api(js)
864
+
865
+ # Behavior Attachments
866
+ js.section("BEHAVIOR ATTACHMENTS")
867
+ self._emit_behavior_attachments(js)
868
+
869
+ # Semantic Behaviors (auto-codegen from prefab attributes)
870
+ js.section("SEMANTIC BEHAVIORS")
871
+ self._emit_semantic_behaviors(js)
872
+
873
+ # Animations registration (after sprites)
874
+ js.section("ANIMATION REGISTRATION")
875
+ self._emit_animation_registration(js)
876
+
877
+ # Input
878
+ js.section("INPUT")
879
+ emit_input_system(js)
880
+ self._emit_controlled_sprites(js)
881
+
882
+ # Mouse
883
+ js.section("MOUSE")
884
+ emit_mouse_system(js)
885
+
886
+ # Functions
887
+ js.section("FUNCTIONS")
888
+ self._emit_functions(js)
889
+
890
+ # Collisions
891
+ js.section("COLLISIONS")
892
+ self._emit_collision_handler(js)
893
+
894
+ # Sound
895
+ js.section("SOUNDS")
896
+ self._emit_sounds(js)
897
+
898
+ # Particles
899
+ js.section("PARTICLES")
900
+ emit_particle_system(js)
901
+ self._emit_particle_instances(js)
902
+
903
+ # Timers
904
+ js.section("TIMERS")
905
+ emit_timer_system(js)
906
+ self._emit_timer_instances(js)
907
+
908
+ # Tweens
909
+ js.section("TWEENS")
910
+ emit_tween_system(js)
911
+ self._emit_tween_instances(js)
912
+
913
+ # Spawners
914
+ js.section("SPAWNERS")
915
+ self._emit_spawner_instances(js)
916
+
917
+ # HUD Update
918
+ js.section("HUD UPDATE")
919
+ self._emit_hud_update(js)
920
+
921
+ # Game Loop
922
+ js.section("GAME LOOP")
923
+ self._emit_game_loop(js)
924
+
925
+ # Scene Start
926
+ js.section("SCENE START")
927
+ self._emit_scene_start(js)
928
+
929
+ # Expose key variables to window for debugging/testing
930
+ js.section("DEBUG EXPORTS")
931
+ js.line("Object.assign(window, { app, mEngine, _sprites, _bodyToSprite, _cameraContainer, _gameEvents });")
932
+
933
+ js.dedent()
934
+ js.line("})();")
935
+
936
+ # ------------------------------------------------------------------
937
+ # Physics
938
+ # ------------------------------------------------------------------
939
+
940
+ def _emit_physics_init(self, js: JsBuilder, scene: SceneNode):
941
+ js.const('mEngine', 'Matter.Engine.create()')
942
+ if self._physics:
943
+ js.assign('mEngine.gravity.x', js_number(self._physics['gravity_x']))
944
+ js.assign('mEngine.gravity.y', js_number(self._physics['gravity_y']))
945
+ else:
946
+ js.assign('mEngine.gravity.x', '0')
947
+ js.assign('mEngine.gravity.y', '0')
948
+ js.comment('Increase solver iterations for tighter collision resolution')
949
+ js.assign('mEngine.positionIterations', '10')
950
+ js.assign('mEngine.velocityIterations', '8')
951
+ js.const('mWorld', 'mEngine.world')
952
+
953
+ if self._physics and self._physics.get('bounds') == 'canvas':
954
+ w, h = scene.width, scene.height
955
+ hw, hh = w / 2, h / 2
956
+ js.comment('World bounds')
957
+ js.const('_bT', f'Matter.Bodies.rectangle({hw}, -25, {w}, 50, {{ isStatic: true }})')
958
+ js.const('_bB', f'Matter.Bodies.rectangle({hw}, {h}+25, {w}, 50, {{ isStatic: true }})')
959
+ js.const('_bL', f'Matter.Bodies.rectangle(-25, {hh}, 50, {h}, {{ isStatic: true }})')
960
+ js.const('_bR', f'Matter.Bodies.rectangle({w}+25, {hh}, 50, {h}, {{ isStatic: true }})')
961
+ js.line("Matter.Composite.add(mWorld, [_bT, _bB, _bL, _bR]);")
962
+
963
+ # ------------------------------------------------------------------
964
+ # State Variables
965
+ # ------------------------------------------------------------------
966
+
967
+ def _emit_state_vars(self, js: JsBuilder):
968
+ if not self._state_vars:
969
+ js.comment('no state')
970
+ return
971
+ for sv in self._state_vars:
972
+ name = js_id(sv['name'])
973
+ val = js_safe_value(sv['value'], sv['type'])
974
+ js.let(name, val)
975
+
976
+ # ------------------------------------------------------------------
977
+ # Assets
978
+ # ------------------------------------------------------------------
979
+
980
+ def _emit_asset_loading(self, js: JsBuilder):
981
+ if not self._assets:
982
+ js.comment('No assets to preload')
983
+ return
984
+ urls = sorted(self._assets)
985
+ asset_list = ', '.join(js_string(u) for u in urls)
986
+ js.line(f"await PIXI.Assets.load([{asset_list}]);")
987
+
988
+ # ------------------------------------------------------------------
989
+ # Behaviors
990
+ # ------------------------------------------------------------------
991
+
992
+ def _emit_behaviors(self, js: JsBuilder):
993
+ if not self._behaviors:
994
+ js.comment('no behaviors')
995
+ return
996
+ for name, bnode in self._behaviors.items():
997
+ cls_name = js_id(name)
998
+ js.class_open(cls_name)
999
+
1000
+ # constructor
1001
+ js.method('constructor', 'owner, overrides')
1002
+ js.assign('this.owner', 'owner')
1003
+ for child in bnode.children:
1004
+ if isinstance(child, SetNode):
1005
+ prop = js_id(child.name)
1006
+ val = js_safe_value(child.value if child.value is not None else (child.default if child.default is not None else ''), child.type or 'string')
1007
+ js.assign(f'this.{prop}', val)
1008
+ js.line("if (overrides) Object.assign(this, overrides);")
1009
+ js.block_close() # constructor
1010
+
1011
+ # Sprite property proxies (x, y, rotation, alpha, visible)
1012
+ # These allow behaviors to access sprite properties directly via this.x, this.y etc.
1013
+ for prop in ['x', 'y', 'rotation', 'alpha', 'visible']:
1014
+ js.line(f"get {prop}() {{ return this.owner.sprite.{prop}; }}")
1015
+ js.line(f"set {prop}(v) {{ this.owner.sprite.{prop} = v; }}")
1016
+
1017
+ # methods
1018
+ for child in bnode.children:
1019
+ if isinstance(child, FunctionNode):
1020
+ self._emit_method(js, child)
1021
+ elif isinstance(child, StateMachineNode):
1022
+ self._emit_state_machine(js, child)
1023
+
1024
+ js.class_close()
1025
+ js.blank()
1026
+
1027
+ def _emit_method(self, js: JsBuilder, fn: FunctionNode):
1028
+ params = ', '.join(js_id(p.name) for p in fn.params)
1029
+ js.method(js_id(fn.name), params)
1030
+ if fn.body:
1031
+ for stmt in fn.body:
1032
+ self._emit_statement(js, stmt)
1033
+ else:
1034
+ js.comment('empty')
1035
+ js.block_close()
1036
+
1037
+ def _emit_state_machine(self, js: JsBuilder, sm: StateMachineNode):
1038
+ # _smInit
1039
+ js.method('_smInit')
1040
+ js.assign('this._state', js_string(sm.initial))
1041
+ js.assign('this._states', '{}')
1042
+ for state in sm.states:
1043
+ transitions = {t.event: t.transition for t in state.transitions}
1044
+ js.assign(f"this._states[{js_string(state.name)}]",
1045
+ f"{{ transitions: {json.dumps(transitions)} }}")
1046
+ enter_fn = f'this._stateEnter_{js_id(sm.initial)}'
1047
+ js.if_block(enter_fn)
1048
+ js.line(f"{enter_fn}();")
1049
+ js.block_close()
1050
+ js.block_close() # _smInit
1051
+
1052
+ # _smUpdate
1053
+ js.method('_smUpdate')
1054
+ for state in sm.states:
1055
+ for child in state.children:
1056
+ if isinstance(child, FunctionNode) and child.name == 'update':
1057
+ js.if_block(f"this._state === {js_string(state.name)}")
1058
+ js.line(f"this._stateUpdate_{js_id(state.name)}();")
1059
+ js.block_close()
1060
+ js.block_close() # _smUpdate
1061
+
1062
+ # Individual state functions
1063
+ for state in sm.states:
1064
+ for child in state.children:
1065
+ if isinstance(child, FunctionNode):
1066
+ prefix = f"_state{child.name.capitalize()}_{js_id(state.name)}"
1067
+ js.method(prefix)
1068
+ for stmt in child.body:
1069
+ self._emit_statement(js, stmt)
1070
+ js.block_close()
1071
+
1072
+ # _smEmit
1073
+ js.method('_smEmit', 'event')
1074
+ js.const('st', 'this._states[this._state]')
1075
+ js.if_block('st && st.transitions[event]')
1076
+ js.const('prev', 'this._state')
1077
+ js.assign('this._state', 'st.transitions[event]')
1078
+ js.const('exitFn', "'_stateExit_' + prev")
1079
+ js.if_block('this[exitFn]')
1080
+ js.line('this[exitFn]();')
1081
+ js.block_close()
1082
+ js.const('enterFn', "'_stateEnter_' + this._state")
1083
+ js.if_block('this[enterFn]')
1084
+ js.line('this[enterFn]();')
1085
+ js.block_close()
1086
+ js.block_close() # if
1087
+ js.block_close() # _smEmit
1088
+
1089
+ # ------------------------------------------------------------------
1090
+ # Statement emission (q:set, q:if, q:loop, q:function inside bodies)
1091
+ # ------------------------------------------------------------------
1092
+
1093
+ def _emit_statement(self, js: JsBuilder, node: QuantumNode):
1094
+ if isinstance(node, SetNode):
1095
+ self._emit_set_assignment(js, node)
1096
+ elif isinstance(node, IfNode):
1097
+ self._emit_if(js, node)
1098
+ elif isinstance(node, LoopNode):
1099
+ self._emit_loop(js, node)
1100
+ elif isinstance(node, FunctionNode):
1101
+ self._emit_nested_function(js, node)
1102
+ elif isinstance(node, RawCodeNode):
1103
+ # Convert self. to this. for JavaScript
1104
+ code = re.sub(r'\bself\.', 'this.', node.code)
1105
+ js.line(code)
1106
+ else:
1107
+ js.comment(f"unsupported node: {type(node).__name__}")
1108
+
1109
+ def _emit_set_assignment(self, js: JsBuilder, node: SetNode):
1110
+ name = node.name
1111
+ val = self._compile_expression(node.value or '""')
1112
+
1113
+ if name.startswith('self.'):
1114
+ prop = name[5:]
1115
+ # Sprite properties that should be proxied to owner.sprite
1116
+ sprite_props = {'x', 'y', 'rotation', 'alpha', 'visible', 'width', 'height'}
1117
+ if prop in sprite_props:
1118
+ js.assign(f'this.owner.sprite.{prop}', val)
1119
+ else:
1120
+ js.assign(f'this.{prop}', val)
1121
+ else:
1122
+ js.assign(js_id(name), val)
1123
+
1124
+ def _emit_if(self, js: JsBuilder, node: IfNode):
1125
+ cond = self._compile_expression(node.condition)
1126
+ js.if_block(cond)
1127
+ for s in node.if_body:
1128
+ self._emit_statement(js, s)
1129
+ for eib in node.elseif_blocks:
1130
+ ec = self._compile_expression(eib['condition'])
1131
+ js.else_if_block(ec)
1132
+ for s in eib['body']:
1133
+ self._emit_statement(js, s)
1134
+ if node.else_body:
1135
+ js.else_block()
1136
+ for s in node.else_body:
1137
+ self._emit_statement(js, s)
1138
+ js.block_close()
1139
+
1140
+ def _emit_loop(self, js: JsBuilder, node: LoopNode):
1141
+ var = js_id(node.var_name)
1142
+ if node.loop_type == 'range':
1143
+ frm = node.from_value or '0'
1144
+ to = node.to_value or '0'
1145
+ step = str(node.step_value or 1)
1146
+ js.for_range(var, frm, to, step)
1147
+ for s in node.body:
1148
+ self._emit_statement(js, s)
1149
+ js.block_close()
1150
+ else:
1151
+ js.comment(f"loop type {node.loop_type} not yet compiled")
1152
+
1153
+ def _emit_nested_function(self, js: JsBuilder, node: FunctionNode):
1154
+ params = ', '.join(js_id(p.name) for p in node.params)
1155
+ js.func(js_id(node.name), params)
1156
+ for s in node.body:
1157
+ self._emit_statement(js, s)
1158
+ js.block_close()
1159
+
1160
+ # ------------------------------------------------------------------
1161
+ # Camera
1162
+ # ------------------------------------------------------------------
1163
+
1164
+ def _emit_camera_setup(self, js: JsBuilder):
1165
+ js.const('_cameraContainer', 'new PIXI.Container()')
1166
+ js.line("app.stage.addChild(_cameraContainer);")
1167
+
1168
+ if self._camera and self._camera.get('follow'):
1169
+ target = js_string(self._camera['follow'])
1170
+ lerp = js_number(self._camera['lerp'])
1171
+ js.const('_camera', f'{{ targetId: {target}, lerp: {lerp} }}')
1172
+ js.blank()
1173
+ js.func('updateCamera')
1174
+ js.const('info', '_sprites[_camera.targetId]')
1175
+ js.if_block('!info')
1176
+ js.ret()
1177
+ js.block_close()
1178
+ js.const('tx', 'app.screen.width / 2 - info.sprite.x')
1179
+ js.const('ty', 'app.screen.height / 2 - info.sprite.y')
1180
+ js.line('_cameraContainer.x += (tx - _cameraContainer.x) * _camera.lerp;')
1181
+ js.line('_cameraContainer.y += (ty - _cameraContainer.y) * _camera.lerp;')
1182
+ if self._camera.get('bounds') == 'scene':
1183
+ js.assign('_cameraContainer.x',
1184
+ f'Math.min(0, Math.max(app.screen.width - {self._scene_width}, _cameraContainer.x))')
1185
+ js.assign('_cameraContainer.y',
1186
+ f'Math.min(0, Math.max(app.screen.height - {self._scene_height}, _cameraContainer.y))')
1187
+ js.block_close() # updateCamera
1188
+ else:
1189
+ js.func('updateCamera')
1190
+ js.block_close()
1191
+
1192
+ # ------------------------------------------------------------------
1193
+ # Sprites & Bodies
1194
+ # ------------------------------------------------------------------
1195
+
1196
+ def _emit_sprites(self, js: JsBuilder):
1197
+ js.const('_sprites', '{}')
1198
+ js.const('_bodyToSprite', '{}')
1199
+ body_vars = []
1200
+
1201
+ for info in self._sprites:
1202
+ sid = js_id(info['id'])
1203
+ src = info['src']
1204
+ x, y = js_number(info['x']), js_number(info['y'])
1205
+ spr_var = f'_spr_{sid}'
1206
+
1207
+ if src:
1208
+ # Check if sprite has frame dimensions (spritesheet with frames)
1209
+ frame_w = info.get('frame_width')
1210
+ frame_h = info.get('frame_height')
1211
+ if frame_w and frame_h:
1212
+ # Spritesheet with frames - cut first frame to avoid wrong initial size
1213
+ js.const(f'_{sid}_baseTex', f"PIXI.Assets.get({js_string(src)})")
1214
+ js.const(f'_{sid}_src', f'_{sid}_baseTex.source || _{sid}_baseTex.baseTexture || _{sid}_baseTex')
1215
+ js.const(f'_{sid}_rect', f'new PIXI.Rectangle(0, 0, {js_number(frame_w)}, {js_number(frame_h)})')
1216
+ js.const(f'_{sid}_frameTex', f'new PIXI.Texture({{ source: _{sid}_src, frame: _{sid}_rect }})')
1217
+ js.const(spr_var, f"new PIXI.Sprite(_{sid}_frameTex)")
1218
+ else:
1219
+ # Normal sprite - load entire image
1220
+ js.const(spr_var, f"PIXI.Sprite.from({js_string(src)})")
1221
+ else:
1222
+ js.const(spr_var, 'new PIXI.Graphics()')
1223
+ if info.get('width') and info.get('height'):
1224
+ w, h = info['width'], info['height']
1225
+ js.line(f"{spr_var}.rect(-{w/2}, -{h/2}, {w}, {h});")
1226
+ # Use color if provided, otherwise default to a visible color
1227
+ color = info.get('color')
1228
+ if color:
1229
+ # Convert hex color like "#FF0000" to 0xFF0000
1230
+ hex_color = color.lstrip('#')
1231
+ js.line(f"{spr_var}.fill({{ color: 0x{hex_color} }});")
1232
+ else:
1233
+ # Default: visible gray rectangle for debugging
1234
+ js.line(f"{spr_var}.fill({{ color: 0x4a9eff }});")
1235
+
1236
+ js.assign(f'{spr_var}.x', x)
1237
+ js.assign(f'{spr_var}.y', y)
1238
+
1239
+ if src:
1240
+ js.line(f"{spr_var}.anchor.set({js_number(info['anchor_x'])}, {js_number(info['anchor_y'])});")
1241
+ # Only set width/height if NOT using frame-based animations
1242
+ has_frame_anims = info.get('frame_width') and info.get('frame_height')
1243
+ if info.get('width') and src and not has_frame_anims:
1244
+ js.assign(f'{spr_var}.width', js_number(info['width']))
1245
+ if info.get('height') and src and not has_frame_anims:
1246
+ js.assign(f'{spr_var}.height', js_number(info['height']))
1247
+ if info['alpha'] != 1.0:
1248
+ js.assign(f'{spr_var}.alpha', js_number(info['alpha']))
1249
+ if not info['visible']:
1250
+ js.assign(f'{spr_var}.visible', 'false')
1251
+
1252
+ js.line(f"_cameraContainer.addChild({spr_var});")
1253
+
1254
+ # Physics body
1255
+ body_var = self._emit_body(js, info, sid)
1256
+ if body_var:
1257
+ body_vars.append(body_var)
1258
+ js.assign(f'_bodyToSprite[{body_var}.id]', js_string(info['id']))
1259
+
1260
+ # Clickable setup
1261
+ if info.get('clickable'):
1262
+ cursor = info['clickable'].get('cursor', 'pointer')
1263
+ action = js_id(info['clickable']['action'])
1264
+ js.assign(f"{spr_var}.eventMode", "'static'")
1265
+ js.assign(f"{spr_var}.cursor", js_string(cursor))
1266
+ sid_str_click = js_string(info['id'])
1267
+ js.line(f"{spr_var}.on('pointerdown', (e) => {{")
1268
+ js.indent()
1269
+ js.const('_info', f'_sprites[{sid_str_click}]')
1270
+ js.comment('Try behavior method first, then global function')
1271
+ js.let('_handled', 'false')
1272
+ js.if_block('_info && _info.behaviors')
1273
+ js.for_of('_b', '_info.behaviors')
1274
+ js.if_block(f"typeof _b.{action} === 'function'")
1275
+ js.line(f"_b.{action}(e);")
1276
+ js.assign('_handled', 'true')
1277
+ js.block_close()
1278
+ js.block_close()
1279
+ js.block_close()
1280
+ js.if_block(f"!_handled && typeof {action} === 'function'")
1281
+ js.line(f"{action}(_info, e);")
1282
+ js.block_close()
1283
+ js.dedent()
1284
+ js.line("});")
1285
+
1286
+ # Register in _sprites
1287
+ body_ref = f'_body_{sid}' if info.get('body') else 'null'
1288
+ tag_ref = js_string(info['tag']) if info.get('tag') else 'null'
1289
+ id_ref = js_string(info['id'])
1290
+ js.line(f"_sprites[{id_ref}] = {{ id: {id_ref}, sprite: {spr_var}, body: {body_ref}, tag: {tag_ref}, collisionHandlers: [], behaviors: [] }};")
1291
+ js.blank()
1292
+
1293
+ if body_vars:
1294
+ js.line(f"Matter.Composite.add(mWorld, [{', '.join(body_vars)}]);")
1295
+
1296
+ js.blank()
1297
+ emit_physics_sync(js)
1298
+
1299
+ def _emit_body(self, js: JsBuilder, info: Dict, sid: str) -> Optional[str]:
1300
+ if not info.get('body'):
1301
+ return None
1302
+ btype = info['body']
1303
+ is_static = btype == 'static'
1304
+ # Use frame dimensions if available (for animated sprites), fallback to width/height or 32
1305
+ w = info.get('width') or info.get('frame_width') or 32
1306
+ h = info.get('height') or info.get('frame_height') or 32
1307
+ x, y = js_number(info['x']), js_number(info['y'])
1308
+
1309
+ collider = info.get('collider')
1310
+ # Shape can come from collider child element or directly from sprite attribute
1311
+ shape = (collider and collider.get('shape')) or info.get('shape') or 'box'
1312
+
1313
+ opts_parts = [f"isStatic: {js_bool(is_static)}"]
1314
+ opts_parts.append(f"restitution: {js_number(info['bounce'])}")
1315
+ opts_parts.append(f"friction: {js_number(info['friction'])}")
1316
+ opts_parts.append("slop: 0.01")
1317
+ if not is_static:
1318
+ opts_parts.append("inertia: Infinity")
1319
+ # Round corners on player to prevent edge-catching on pipes/platforms
1320
+ if info.get('tag') == 'player':
1321
+ opts_parts.append("chamfer: { radius: 6 }")
1322
+ # Round corners on tilemap collision tiles to allow step-up transitions
1323
+ if is_static and info.get('tag') == 'tilemap-collision':
1324
+ opts_parts.append("chamfer: { radius: 4 }")
1325
+ if info.get('mass') is not None:
1326
+ opts_parts.append(f"mass: {js_number(info['mass'])}")
1327
+ if info.get('sensor'):
1328
+ opts_parts.append("isSensor: true")
1329
+ opts = ', '.join(opts_parts)
1330
+
1331
+ body_var = f'_body_{sid}'
1332
+ if shape == 'circle':
1333
+ r = js_number((collider and collider.get('radius')) or w / 2)
1334
+ js.const(body_var, f"Matter.Bodies.circle({x}, {y}, {r}, {{ {opts} }})")
1335
+ elif shape == 'slope-right':
1336
+ # Right triangle: flat bottom, slope going up to the right
1337
+ # Vertices: bottom-left, bottom-right, top-right
1338
+ hw = js_number(w / 2)
1339
+ hh = js_number(h / 2)
1340
+ js.const(body_var, f"Matter.Bodies.fromVertices({x}, {y}, [" +
1341
+ f"{{ x: -{hw}, y: {hh} }}, " + # bottom-left
1342
+ f"{{ x: {hw}, y: {hh} }}, " + # bottom-right
1343
+ f"{{ x: {hw}, y: -{hh} }}" + # top-right
1344
+ f"], {{ {opts} }})")
1345
+ elif shape == 'slope-left':
1346
+ # Right triangle: flat bottom, slope going up to the left
1347
+ # Vertices: bottom-left, bottom-right, top-left
1348
+ hw = js_number(w / 2)
1349
+ hh = js_number(h / 2)
1350
+ js.const(body_var, f"Matter.Bodies.fromVertices({x}, {y}, [" +
1351
+ f"{{ x: -{hw}, y: {hh} }}, " + # bottom-left
1352
+ f"{{ x: {hw}, y: {hh} }}, " + # bottom-right
1353
+ f"{{ x: -{hw}, y: -{hh} }}" + # top-left
1354
+ f"], {{ {opts} }})")
1355
+ else:
1356
+ js.const(body_var, f"Matter.Bodies.rectangle({x}, {y}, {js_number(w)}, {js_number(h)}, {{ {opts} }})")
1357
+ return body_var
1358
+
1359
+ # ------------------------------------------------------------------
1360
+ # Behavior Attachments
1361
+ # ------------------------------------------------------------------
1362
+
1363
+ def _emit_behavior_attachments(self, js: JsBuilder):
1364
+ has_any = False
1365
+ for info in self._sprites:
1366
+ sid_str = js_string(info['id'])
1367
+ for buse in info.get('behaviors', []):
1368
+ has_any = True
1369
+ bname = js_id(buse['name'])
1370
+ overrides = json.dumps(buse['overrides']) if buse.get('overrides') else '{}'
1371
+ js.line(f"_sprites[{sid_str}].behaviors.push(new {bname}(_sprites[{sid_str}], {overrides}));")
1372
+ # The attribute pair, plus any <qg:on-collision> children. A
1373
+ # behavior needs more than one handler often enough — turn at
1374
+ # a wall, turn at a ledge — and one attribute holds one.
1375
+ handlers = []
1376
+ if buse.get('on_collision'):
1377
+ handlers.append((buse['on_collision'], buse.get('collision_tag')))
1378
+ for extra in buse.get('extra_collisions') or []:
1379
+ if extra.get('handler'):
1380
+ handlers.append((extra['handler'], extra.get('tag')))
1381
+
1382
+ for handler_name, handler_tag in handlers:
1383
+ fn_name = js_id(handler_name)
1384
+ js.line(f"_sprites[{sid_str}].collisionHandlers.push((self, other) => {{")
1385
+ js.indent()
1386
+ if handler_tag:
1387
+ tag_val = js_string(handler_tag)
1388
+ js.line(f"if (other.tag !== {tag_val}) return;")
1389
+ js.line(f"const b = self.behaviors.find(b => b instanceof {bname});")
1390
+ js.line(f"if (b && b.{fn_name}) {{ b._other = other; b.{fn_name}(other); }}")
1391
+ js.dedent()
1392
+ js.line("});")
1393
+
1394
+ # Process inline on-collision handlers (qg:on-collision)
1395
+ for oc in info.get('on_collisions', []):
1396
+ has_any = True
1397
+ js.line(f"_sprites[{sid_str}].collisionHandlers.push((self, other) => {{")
1398
+ js.indent()
1399
+ # Tag filter
1400
+ if oc.get('with_tag'):
1401
+ tag_val = js_string(oc['with_tag'])
1402
+ js.line(f"if (other.tag !== {tag_val}) return;")
1403
+ # ID filter
1404
+ if oc.get('with_id'):
1405
+ id_val = js_string(oc['with_id'])
1406
+ js.line(f"if (other.sprite?.name !== {id_val}) return;")
1407
+ # Execute action
1408
+ action = oc.get('action', '')
1409
+ if action == 'destroy-self':
1410
+ js.line('if (self._destroyed) return;')
1411
+ js.line('self._destroyed = true;')
1412
+ js.line('if (self.body) { delete _bodyToSprite[self.body.id]; Matter.Composite.remove(mEngine.world, self.body); }')
1413
+ js.line('if (self.sprite) { if (self.sprite.stop) self.sprite.stop(); self.sprite.destroy(); }')
1414
+ js.line('if (self.id) delete _sprites[self.id];')
1415
+ elif action == 'destroy-other':
1416
+ js.line('if (other._destroyed) return;')
1417
+ js.line('other._destroyed = true;')
1418
+ js.line('if (other.body) { delete _bodyToSprite[other.body.id]; Matter.Composite.remove(mEngine.world, other.body); }')
1419
+ js.line('if (other.sprite) { if (other.sprite.stop) other.sprite.stop(); other.sprite.destroy(); }')
1420
+ js.line('if (other.id) delete _sprites[other.id];')
1421
+ elif action.startswith('emit:'):
1422
+ event_name = action[5:] # Remove 'emit:' prefix
1423
+ js.line(f"_gameEvents.emit({js_string(event_name)}, {{ self, other }});")
1424
+ elif action.startswith('call:'):
1425
+ fn_name = action[5:] # Remove 'call:' prefix
1426
+ js.line(f"if (typeof {js_id(fn_name)} === 'function') {js_id(fn_name)}(self, other);")
1427
+ js.dedent()
1428
+ js.line("});")
1429
+
1430
+ if not has_any:
1431
+ js.comment('no behavior attachments')
1432
+
1433
+ # Init state machines
1434
+ js.comment('Init state machines')
1435
+ js.for_entries('_id', '_info', '_sprites')
1436
+ js.for_of('b', '_info.behaviors')
1437
+ js.if_block('b._smInit')
1438
+ js.line('b._smInit();')
1439
+ js.block_close()
1440
+ js.block_close()
1441
+ js.block_close()
1442
+
1443
+ # ------------------------------------------------------------------
1444
+ # Semantic Behaviors (auto-codegen from prefab attributes)
1445
+ # ------------------------------------------------------------------
1446
+
1447
+ def _emit_semantic_behaviors(self, js: JsBuilder):
1448
+ """Emit auto-generated JS behaviors based on prefab semantic attributes."""
1449
+ has_any = False
1450
+ for info in self._sprites:
1451
+ sem = info.get('_prefab_semantics')
1452
+ if not sem:
1453
+ continue
1454
+ entity_type = sem.get('entity_type')
1455
+ if not entity_type:
1456
+ continue
1457
+ has_any = True
1458
+ if entity_type == 'enemy':
1459
+ self._emit_enemy_semantics(js, info, sem)
1460
+ elif entity_type == 'item':
1461
+ self._emit_item_semantics(js, info, sem)
1462
+ elif entity_type == 'block':
1463
+ self._emit_block_semantics(js, info, sem)
1464
+ elif entity_type == 'hazard':
1465
+ self._emit_hazard_semantics(js, info, sem)
1466
+ elif entity_type == 'goal':
1467
+ self._emit_goal_semantics(js, info, sem)
1468
+ if not has_any:
1469
+ js.comment('no semantic behaviors')
1470
+
1471
+ def _emit_enemy_semantics(self, js: JsBuilder, info: dict, sem: dict):
1472
+ """Emit enemy behavior: movement + stomp/defeat handler."""
1473
+ sid = info['id']
1474
+ sid_str = js_string(sid)
1475
+ js.comment(f'Enemy semantics: {sid}')
1476
+
1477
+ # Movement setup
1478
+ movement = sem.get('movement', 'patrol')
1479
+ speed = sem.get('move_speed', 1)
1480
+ facing = sem.get('facing', 'left')
1481
+ effective_speed = -abs(speed) if facing == 'left' else abs(speed)
1482
+
1483
+ if movement == 'patrol':
1484
+ js.line(f"game.setGoombaAI({sid_str}, {js_number(effective_speed)});")
1485
+ elif movement == 'chase':
1486
+ js.line(f"_sprites[{sid_str}]._semMovement = 'chase';")
1487
+ js.line(f"_sprites[{sid_str}]._semMoveSpeed = {js_number(speed)};")
1488
+
1489
+ # Stomp/defeat handler
1490
+ defeated_by = sem.get('defeated_by')
1491
+ if defeated_by == 'stomp':
1492
+ health = sem.get('health', 1)
1493
+ stomp_bounce = sem.get('stomp_bounce', -5)
1494
+ contact_damage = sem.get('contact_damage', 1)
1495
+ on_defeat = sem.get('on_defeat', 'enemy-killed')
1496
+ reward_kill = sem.get('reward_kill')
1497
+
1498
+ js.line(f"_sprites[{sid_str}]._semHealth = {js_number(health)};")
1499
+ js.blank()
1500
+ js.comment(f'Stomp handler for {sid}')
1501
+ js.line(f"_sprites[{sid_str}].collisionHandlers.push((self, other) => {{")
1502
+ js.indent()
1503
+ # Stomp detection: player feet above enemy head + player is falling
1504
+ js.line('const selfBody = self.body;')
1505
+ js.line('const otherBody = other.body;')
1506
+ js.if_block('!selfBody || !otherBody')
1507
+ js.ret()
1508
+ js.block_close()
1509
+ js.comment('Stomp detection: other (player) is above and falling')
1510
+ js.line('const otherBottom = otherBody.position.y + otherBody.bounds.max.y - otherBody.bounds.min.y;')
1511
+ js.line('const selfTop = selfBody.position.y - (selfBody.bounds.max.y - selfBody.bounds.min.y) / 2;')
1512
+ js.line('const isFalling = otherBody.velocity.y > 0;')
1513
+ js.line('const isAbove = otherBottom <= selfTop + 10;')
1514
+ js.blank()
1515
+ js.if_block('isAbove && isFalling')
1516
+ # Stomped
1517
+ js.comment('Stomped!')
1518
+ js.line(f"self._semHealth = (self._semHealth || {js_number(health)}) - 1;")
1519
+ js.line(f"game.emit('enemy-stomped', {{ self: self.id, other: other.id, id: self.id }});")
1520
+ js.line(f"Matter.Body.setVelocity(otherBody, {{ x: otherBody.velocity.x, y: {js_number(stomp_bounce)} }});")
1521
+ js.if_block('self._semHealth <= 0')
1522
+ js.line(f"game.destroySprite(self.id);")
1523
+ js.line(f"game.emit({js_string(on_defeat)}, {{ self: self.id, other: other.id, id: self.id }});")
1524
+ if reward_kill:
1525
+ js.line(f"game.emit('reward', {{ type: {js_string(reward_kill)}, source: self.id }});")
1526
+ js.block_close()
1527
+ js.line('} else {')
1528
+ js.indent()
1529
+ # Side collision - damage player
1530
+ js.line(f"game.emit('player-damaged', {{ damage: {js_number(contact_damage)}, source: self.id, player: other.id }});")
1531
+ js.dedent()
1532
+ js.line('}')
1533
+ js.dedent()
1534
+ js.line("});")
1535
+ js.blank()
1536
+
1537
+ def _emit_item_semantics(self, js: JsBuilder, info: dict, sem: dict):
1538
+ """Emit item behavior: collectible + optional auto-move."""
1539
+ sid = info['id']
1540
+ sid_str = js_string(sid)
1541
+ js.comment(f'Item semantics: {sid}')
1542
+
1543
+ # Auto-move (like mushroom)
1544
+ if sem.get('auto_move'):
1545
+ speed = sem.get('move_speed', 1)
1546
+ facing = sem.get('facing', 'right')
1547
+ effective_speed = -abs(speed) if facing == 'left' else abs(speed)
1548
+ js.line(f"game.setGoombaAI({sid_str}, {js_number(effective_speed)});")
1549
+
1550
+ # Collectible handler
1551
+ effect = sem.get('effect')
1552
+ reward = sem.get('reward')
1553
+ js.line(f"_sprites[{sid_str}].collisionHandlers.push((self, other) => {{")
1554
+ js.indent()
1555
+ event_data_parts = ['self: self.id', 'other: other.id', 'id: self.id']
1556
+ if effect:
1557
+ event_data_parts.append(f"effect: {js_string(effect)}")
1558
+ if reward:
1559
+ event_data_parts.append(f"reward: {js_string(reward)}")
1560
+ event_data = '{ ' + ', '.join(event_data_parts) + ' }'
1561
+ js.line(f"game.emit('item-collected', {event_data});")
1562
+ js.line(f"game.destroySprite(self.id);")
1563
+ js.dedent()
1564
+ js.line("});")
1565
+ js.blank()
1566
+
1567
+ def _emit_block_semantics(self, js: JsBuilder, info: dict, sem: dict):
1568
+ """Emit block behavior: hit from below handler."""
1569
+ sid = info['id']
1570
+ sid_str = js_string(sid)
1571
+ hits = sem.get('hits', 1)
1572
+ content = sem.get('content')
1573
+ breakable = sem.get('breakable', False)
1574
+ js.comment(f'Block semantics: {sid}')
1575
+
1576
+ js.line(f"_sprites[{sid_str}]._semHitsLeft = {js_number(hits)};")
1577
+ js.line(f"_sprites[{sid_str}].collisionHandlers.push((self, other) => {{")
1578
+ js.indent()
1579
+ js.line('const selfBody = self.body;')
1580
+ js.line('const otherBody = other.body;')
1581
+ js.if_block('!selfBody || !otherBody')
1582
+ js.ret()
1583
+ js.block_close()
1584
+ js.comment('Hit from below: other (player) is beneath and moving up')
1585
+ js.line('const otherTop = otherBody.position.y - (otherBody.bounds.max.y - otherBody.bounds.min.y) / 2;')
1586
+ js.line('const selfBottom = selfBody.position.y + (selfBody.bounds.max.y - selfBody.bounds.min.y) / 2;')
1587
+ js.line('const isBelow = otherTop >= selfBottom - 10;')
1588
+ js.line('const isMovingUp = otherBody.velocity.y < 0;')
1589
+ js.if_block('isBelow && isMovingUp && self._semHitsLeft > 0')
1590
+ js.line('self._semHitsLeft -= 1;')
1591
+ content_data = f", content: {js_string(content)}" if content else ''
1592
+ js.line(f"game.emit('block-hit', {{ self: self.id, other: other.id, id: self.id{content_data} }});")
1593
+ js.if_block('self._semHitsLeft <= 0')
1594
+ if breakable:
1595
+ js.line(f"game.destroySprite(self.id);")
1596
+ js.line(f"game.emit('block-broken', {{ self: self.id, other: other.id, id: self.id }});")
1597
+ else:
1598
+ js.line("self._blockUsed = true;")
1599
+ js.block_close()
1600
+ js.block_close()
1601
+ js.dedent()
1602
+ js.line("});")
1603
+ js.blank()
1604
+
1605
+ def _emit_hazard_semantics(self, js: JsBuilder, info: dict, sem: dict):
1606
+ """Emit hazard behavior: contact damage on touch."""
1607
+ sid = info['id']
1608
+ sid_str = js_string(sid)
1609
+ damage = sem.get('contact_damage', 1)
1610
+ js.comment(f'Hazard semantics: {sid}')
1611
+
1612
+ js.line(f"_sprites[{sid_str}].collisionHandlers.push((self, other) => {{")
1613
+ js.indent()
1614
+ js.line(f"game.emit('player-damaged', {{ damage: {js_number(damage)}, source: self.id, player: other.id }});")
1615
+ js.dedent()
1616
+ js.line("});")
1617
+ js.blank()
1618
+
1619
+ def _emit_goal_semantics(self, js: JsBuilder, info: dict, sem: dict):
1620
+ """Emit goal behavior: level-complete on contact."""
1621
+ sid = info['id']
1622
+ sid_str = js_string(sid)
1623
+ js.comment(f'Goal semantics: {sid}')
1624
+
1625
+ js.line(f"_sprites[{sid_str}].collisionHandlers.push((self, other) => {{")
1626
+ js.indent()
1627
+ js.line(f"game.emit('level-complete', {{ self: self.id, other: other.id }});")
1628
+ js.dedent()
1629
+ js.line("});")
1630
+ js.blank()
1631
+
1632
+ # ------------------------------------------------------------------
1633
+ # Animation Registration
1634
+ # ------------------------------------------------------------------
1635
+
1636
+ def _emit_animation_registration(self, js: JsBuilder):
1637
+ has_anims = False
1638
+ for info in self._sprites:
1639
+ if info.get('animations') and info.get('frame_width') and info.get('frame_height'):
1640
+ has_anims = True
1641
+ sid_str = js_string(info['id'])
1642
+ anims_obj_parts = []
1643
+ for anim in info['animations']:
1644
+ a_name = js_string(anim['name'])
1645
+ a_frames = js_string(anim['frames'])
1646
+ a_speed = js_number(anim['speed'])
1647
+ a_loop = js_bool(anim['loop'])
1648
+ a_auto = js_bool(anim.get('auto_play', False))
1649
+ anims_obj_parts.append(
1650
+ f"{a_name}: {{ frames: {a_frames}, speed: {a_speed}, loop: {a_loop}, autoPlay: {a_auto} }}"
1651
+ )
1652
+ anims_obj = '{ ' + ', '.join(anims_obj_parts) + ' }'
1653
+ fw = js_number(info['frame_width'])
1654
+ fh = js_number(info['frame_height'])
1655
+ js.line(f"_registerAnimation({sid_str}, PIXI.Assets.get({js_string(info['src'])}), {fw}, {fh}, {anims_obj});")
1656
+ if not has_anims:
1657
+ js.comment('no animations to register')
1658
+
1659
+ # ------------------------------------------------------------------
1660
+ # Controlled Sprites (for input/animation auto-switch)
1661
+ # ------------------------------------------------------------------
1662
+
1663
+ def _emit_controlled_sprites(self, js: JsBuilder):
1664
+ js.const('_controlledSprites', '{}')
1665
+ for info in self._sprites:
1666
+ if info.get('controls'):
1667
+ ctrl = info['controls']
1668
+ sid_str = js_string(info['id'])
1669
+ if ctrl == 'wasd':
1670
+ js.assign(f'_controlledSprites[{sid_str}]',
1671
+ "{ left: 'a', right: 'd', up: 'w', jump: ' ',"
1672
+ " left2: null, right2: null, up2: null, jump2: null }")
1673
+ elif ctrl == 'arrows':
1674
+ js.assign(f'_controlledSprites[{sid_str}]',
1675
+ "{ left: 'ArrowLeft', right: 'ArrowRight', up: 'ArrowUp', jump: 'ArrowUp',"
1676
+ " left2: 'a', right2: 'd', up2: 'w', jump2: 'w' }")
1677
+
1678
+ # ------------------------------------------------------------------
1679
+ # Functions
1680
+ # ------------------------------------------------------------------
1681
+
1682
+ def _emit_functions(self, js: JsBuilder):
1683
+ if not self._functions:
1684
+ js.comment('no functions')
1685
+ return
1686
+
1687
+ # Collect event handler names to auto-add 'data' parameter
1688
+ event_handlers = {evt['handler'] for evt in self._events}
1689
+
1690
+ for fn in self._functions:
1691
+ params = ', '.join(js_id(p.name) for p in fn.params)
1692
+ # Auto-add 'data' param for event handlers if not already declared
1693
+ if fn.name in event_handlers and not fn.params:
1694
+ params = 'data'
1695
+ js.func(js_id(fn.name), params)
1696
+ for stmt in fn.body:
1697
+ self._emit_statement(js, stmt)
1698
+ js.block_close()
1699
+ js.blank()
1700
+
1701
+ # ------------------------------------------------------------------
1702
+ # Collision handler
1703
+ # ------------------------------------------------------------------
1704
+
1705
+ def _emit_collision_handler(self, js: JsBuilder):
1706
+ has_behavior_collisions = any(
1707
+ buse.get('on_collision')
1708
+ for info in self._sprites
1709
+ for buse in info.get('behaviors', [])
1710
+ )
1711
+ has_inline_collisions = any(
1712
+ len(info.get('on_collisions', [])) > 0
1713
+ for info in self._sprites
1714
+ )
1715
+ has_semantic_collisions = any(
1716
+ info.get('_prefab_semantics', {}).get('entity_type') in ('enemy', 'item', 'block', 'hazard', 'goal')
1717
+ for info in self._sprites
1718
+ )
1719
+ if not has_behavior_collisions and not has_inline_collisions and not has_semantic_collisions:
1720
+ js.comment('no collision handlers')
1721
+ return
1722
+
1723
+ js.line("Matter.Events.on(mEngine, 'collisionStart', (event) => {")
1724
+ js.indent()
1725
+ js.for_of('pair', 'event.pairs')
1726
+ js.const('a', '_bodyToSprite[pair.bodyA.id]')
1727
+ js.const('b', '_bodyToSprite[pair.bodyB.id]')
1728
+ js.if_block('a && b')
1729
+ js.line('_handleCollision(a, b);')
1730
+ js.line('_handleCollision(b, a);')
1731
+ js.block_close()
1732
+ js.block_close()
1733
+ js.dedent()
1734
+ js.line("});")
1735
+ js.blank()
1736
+
1737
+ js.func('_handleCollision', 'selfId, otherId')
1738
+ js.const('selfInfo', '_sprites[selfId]')
1739
+ js.const('otherInfo', '_sprites[otherId]')
1740
+ js.if_block('!selfInfo || !otherInfo')
1741
+ js.ret()
1742
+ js.block_close()
1743
+ js.if_block('selfInfo.collisionHandlers')
1744
+ js.for_of('handler', 'selfInfo.collisionHandlers')
1745
+ js.line('handler(selfInfo, otherInfo);')
1746
+ js.block_close()
1747
+ js.block_close()
1748
+ js.block_close()
1749
+
1750
+ # ------------------------------------------------------------------
1751
+ # Sound
1752
+ # ------------------------------------------------------------------
1753
+
1754
+ def _emit_sounds(self, js: JsBuilder):
1755
+ if not self._sounds:
1756
+ js.comment('No sounds')
1757
+ return
1758
+ emit_audio_system(js)
1759
+ for s in self._sounds:
1760
+ js.line(f"await _loadSound({js_string(s['id'])}, {js_string(s['src'])});")
1761
+
1762
+ # ------------------------------------------------------------------
1763
+ # Particle instances
1764
+ # ------------------------------------------------------------------
1765
+
1766
+ def _emit_particle_instances(self, js: JsBuilder):
1767
+ if not self._particles:
1768
+ js.comment('No particle instances')
1769
+ return
1770
+ for p in self._particles:
1771
+ config_parts = [
1772
+ f"id: {js_string(p['id'])}",
1773
+ f"follow: {js_string(p['follow']) if p.get('follow') else 'null'}",
1774
+ f"count: {js_number(p['count'])}",
1775
+ f"emitRate: {js_number(p['emit_rate'])}",
1776
+ f"lifetime: {js_number(p['lifetime'])}",
1777
+ f"speedMin: {js_number(p['speed_min'])}",
1778
+ f"speedMax: {js_number(p['speed_max'])}",
1779
+ f"angleMin: {js_number(p['angle_min'])}",
1780
+ f"angleMax: {js_number(p['angle_max'])}",
1781
+ f"alphaStart: {js_number(p['alpha_start'])}",
1782
+ f"alphaEnd: {js_number(p['alpha_end'])}",
1783
+ ]
1784
+ js.line(f"_createParticleSystem({{ {', '.join(config_parts)} }});")
1785
+ if p.get('trigger'):
1786
+ js.comment(f"trigger: {p['trigger']} (wired at scene start)")
1787
+
1788
+ # ------------------------------------------------------------------
1789
+ # Timer instances
1790
+ # ------------------------------------------------------------------
1791
+
1792
+ def _emit_timer_instances(self, js: JsBuilder):
1793
+ if not self._timers:
1794
+ js.comment('No timer instances')
1795
+ return
1796
+ for t in self._timers:
1797
+ action = js_id(t['action'])
1798
+ js.line(
1799
+ f"_createTimer({js_string(t['id'])}, {js_number(t['interval'])}, "
1800
+ f"{js_number(t['repeat'])}, {js_bool(t['auto_start'])}, "
1801
+ f"typeof {action} === 'function' ? {action} : function(){{}});"
1802
+ )
1803
+
1804
+ # ------------------------------------------------------------------
1805
+ # Tween instances
1806
+ # ------------------------------------------------------------------
1807
+
1808
+ def _emit_tween_instances(self, js: JsBuilder):
1809
+ if not self._tweens:
1810
+ js.comment('No tween instances')
1811
+ return
1812
+ for t in self._tweens:
1813
+ config_parts = [
1814
+ f"id: {js_string(t['id'])}",
1815
+ f"target: {js_string(t['target'])}",
1816
+ f"prop: {js_string(t['property'])}",
1817
+ f"to: {js_number(t['to'])}",
1818
+ f"duration: {js_number(t['duration'])}",
1819
+ f"easing: {js_string(t['easing'])}",
1820
+ f"loop: {js_bool(t['loop'])}",
1821
+ f"yoyo: {js_bool(t['yoyo'])}",
1822
+ f"delay: {js_number(t['delay'])}",
1823
+ f"active: {js_bool(t['auto_start'])}",
1824
+ ]
1825
+ js.line(f"_createTween({{ {', '.join(config_parts)} }});")
1826
+
1827
+ # ------------------------------------------------------------------
1828
+ # HUD
1829
+ # ------------------------------------------------------------------
1830
+
1831
+ def _build_hud_html(self) -> str:
1832
+ parts = []
1833
+ for i, hud in enumerate(self._huds):
1834
+ content = self._gen_hud_content_safe(hud['children'])
1835
+ div = HUD_DIV_TEMPLATE.format(
1836
+ position=hud['position'],
1837
+ index=i,
1838
+ content=content,
1839
+ )
1840
+ parts.append(f" {div}")
1841
+ return '\n'.join(parts)
1842
+
1843
+ def _gen_hud_content_safe(self, children: List) -> str:
1844
+ """Generate HUD HTML with sanitized content."""
1845
+ parts = []
1846
+ for child in children:
1847
+ if isinstance(child, HTMLNode):
1848
+ tag = re.sub(r'[^a-zA-Z0-9]', '', child.tag) # sanitize tag name
1849
+ safe_attrs = []
1850
+ for k, v in child.attributes.items():
1851
+ k_safe = re.sub(r'[^a-zA-Z0-9-]', '', k)
1852
+ # Only allow safe attributes
1853
+ if k_safe in ('style', 'class', 'id'):
1854
+ v_safe = v.replace('"', '&quot;').replace('<', '&lt;').replace('>', '&gt;')
1855
+ safe_attrs.append(f'{k_safe}="{v_safe}"')
1856
+ attrs_str = ' ' + ' '.join(safe_attrs) if safe_attrs else ''
1857
+ inner = self._gen_hud_content_safe(child.children)
1858
+ parts.append(f"<{tag}{attrs_str}>{inner}</{tag}>")
1859
+ elif isinstance(child, TextNode):
1860
+ parts.append(sanitize_hud_text(child.content))
1861
+ else:
1862
+ parts.append('')
1863
+ return ''.join(parts)
1864
+
1865
+ def _emit_hud_update(self, js: JsBuilder):
1866
+ if not self._huds:
1867
+ js.comment('No HUD')
1868
+ return
1869
+
1870
+ js.const('_hudElements', '[]')
1871
+ for i in range(len(self._huds)):
1872
+ js.line(f"_hudElements.push(document.getElementById('qg-hud-{i}'));")
1873
+
1874
+ js.func('_updateHUD')
1875
+ for i, hud in enumerate(self._huds):
1876
+ raw_content = self._get_raw_hud_text(hud['children'])
1877
+ bindings = re.findall(r'\{([^}]+)\}', raw_content)
1878
+ if bindings:
1879
+ js_expr = compile_binding_to_js(raw_content)
1880
+ # Use textContent instead of innerHTML for safety
1881
+ js.if_block(f'_hudElements[{i}]')
1882
+ js.assign(f'_hudElements[{i}].textContent', js_expr)
1883
+ js.block_close()
1884
+ js.block_close()
1885
+
1886
+ def _get_raw_hud_text(self, children: List) -> str:
1887
+ """Get raw text content from HUD children for binding extraction."""
1888
+ parts = []
1889
+ for child in children:
1890
+ if isinstance(child, HTMLNode):
1891
+ parts.append(self._get_raw_hud_text(child.children))
1892
+ elif isinstance(child, TextNode):
1893
+ parts.append(child.content)
1894
+ return ''.join(parts)
1895
+
1896
+ # ------------------------------------------------------------------
1897
+ # Game Loop
1898
+ # ------------------------------------------------------------------
1899
+
1900
+ def _emit_game_loop(self, js: JsBuilder):
1901
+ js.line("app.ticker.add((ticker) => {")
1902
+ js.indent()
1903
+ js.const('dt', 'ticker.deltaTime')
1904
+
1905
+ # Physics update with fixed timestep sub-stepping to prevent tunneling
1906
+ js.comment('Sub-step physics with velocity clamping to prevent tunneling')
1907
+ js.const('_fixedDt', '16.67') # ~60fps fixed step
1908
+ js.const('_maxSteps', '3') # Cap to prevent spiral of death
1909
+ js.const('_maxVel', '12') # Max velocity per step (must be < tile height)
1910
+ js.const('_steps', 'Math.min(Math.ceil(dt), _maxSteps)')
1911
+ js.for_range('_i', '0', '_steps')
1912
+ js.comment('Clamp velocities BEFORE physics step')
1913
+ js.for_entries('_sid', '_sinfo', '_sprites')
1914
+ js.if_block('_sinfo.body && !_sinfo.body.isStatic')
1915
+ js.const('_bv', '_sinfo.body.velocity')
1916
+ js.if_block('Math.abs(_bv.x) > _maxVel || Math.abs(_bv.y) > _maxVel')
1917
+ js.line('Matter.Body.setVelocity(_sinfo.body, {')
1918
+ js.line(' x: Math.max(-_maxVel, Math.min(_maxVel, _bv.x)),')
1919
+ js.line(' y: Math.max(-_maxVel, Math.min(_maxVel, _bv.y))')
1920
+ js.line('});')
1921
+ js.block_close()
1922
+ js.block_close()
1923
+ js.block_close()
1924
+ js.line('Matter.Engine.update(mEngine, _fixedDt);')
1925
+ js.block_close()
1926
+ js.line('syncPhysics();')
1927
+
1928
+ # Tilemap collision resolution (after physics, before input)
1929
+ if self._collision_grids:
1930
+ js.line('_resolveTilemapCollisions();')
1931
+ js.line('syncPhysics();') # Re-sync after tilemap adjustment
1932
+
1933
+ # Input handling for controlled sprites
1934
+ js.comment('Input handling')
1935
+ self._emit_input_update_loop(js)
1936
+
1937
+ # Camera
1938
+ js.line('updateCamera();')
1939
+
1940
+ # Control-based animation switching
1941
+ js.line('_updateControlAnimations();')
1942
+
1943
+ # Update animated sprites (PIXI v8 fix)
1944
+ js.line('_updateAnimatedSprites(ticker);')
1945
+
1946
+ # Behaviors update
1947
+ js.comment('Behaviors update')
1948
+ js.for_entries('_id', '_info', '_sprites')
1949
+ js.if_block('_info.behaviors')
1950
+ js.for_of('b', '_info.behaviors')
1951
+ js.if_block('b._smUpdate')
1952
+ js.line('b._smUpdate();')
1953
+ js.block_close()
1954
+ js.if_block('b.update')
1955
+ js.line('b.update();')
1956
+ js.block_close()
1957
+ js.block_close()
1958
+ js.block_close()
1959
+ js.block_close()
1960
+
1961
+ # Timers
1962
+ if self._timers:
1963
+ js.line('_updateTimers(dt);')
1964
+
1965
+ # Tweens
1966
+ if self._tweens:
1967
+ js.line('_updateTweens(dt);')
1968
+
1969
+ # Particles
1970
+ if self._particles:
1971
+ js.line('_updateParticles(dt);')
1972
+
1973
+ # HUD
1974
+ if self._huds:
1975
+ js.line('_updateHUD();')
1976
+
1977
+ js.dedent()
1978
+ js.line("});")
1979
+
1980
+ def _emit_input_update_loop(self, js: JsBuilder):
1981
+ for info in self._sprites:
1982
+ if info.get('controls'):
1983
+ sid_str = js_string(info['id'])
1984
+ sid_js = js_id(info['id'])
1985
+ ctrl = info['controls']
1986
+ spd = js_number(info['speed'])
1987
+ jf = js_number(info['jump_force'])
1988
+
1989
+ # SMW-style physics parameters
1990
+ gravity_up = info.get('gravity_up')
1991
+ gravity_down = info.get('gravity_down')
1992
+ jump_hold_boost = js_number(info.get('jump_hold_boost', 0.4))
1993
+ coyote_frames = js_number(info.get('coyote_frames', 6))
1994
+ max_fall_speed = js_number(info.get('max_fall_speed', 15.0))
1995
+
1996
+ if ctrl in ('wasd', 'arrows'):
1997
+ js.const(f'_ctrl_{sid_js}', f'_controlledSprites[{sid_str}]')
1998
+ ctrl_var = f'_ctrl_{sid_js}'
1999
+ body_ref = f'_sprites[{sid_str}].body'
2000
+ sprite_ref = f'_sprites[{sid_str}]'
2001
+
2002
+ js.comment("Movement with dual key support (arrows + WASD)")
2003
+ js.const(f'_leftPressed_{sid_js}',
2004
+ f'_keys[{ctrl_var}.left] || (_keys[{ctrl_var}.left2] || false)')
2005
+ js.const(f'_rightPressed_{sid_js}',
2006
+ f'_keys[{ctrl_var}.right] || (_keys[{ctrl_var}.right2] || false)')
2007
+ js.const(f'_jumpPressed_{sid_js}',
2008
+ f'_keys[{ctrl_var}.jump] || (_keys[{ctrl_var}.jump2] || false)')
2009
+ js.const(f'_jumpJust_{sid_js}',
2010
+ f'_justPressed[{ctrl_var}.jump] || (_justPressed[{ctrl_var}.jump2] || false)')
2011
+ left_var = f'_leftPressed_{sid_js}'
2012
+ right_var = f'_rightPressed_{sid_js}'
2013
+ jump_var = f'_jumpPressed_{sid_js}'
2014
+ jump_just = f'_jumpJust_{sid_js}'
2015
+
2016
+ # Horizontal movement
2017
+ js.if_block(f'{left_var} && {body_ref}')
2018
+ js.line(f"Matter.Body.setVelocity({body_ref}, {{ x: -{spd}, y: {body_ref}.velocity.y }});")
2019
+ js.line(f"{sprite_ref}.sprite.scale.x = Math.abs({sprite_ref}.sprite.scale.x);")
2020
+ js.block_close()
2021
+ js.if_block(f'{right_var} && {body_ref}')
2022
+ js.line(f"Matter.Body.setVelocity({body_ref}, {{ x: {spd}, y: {body_ref}.velocity.y }});")
2023
+ js.line(f"{sprite_ref}.sprite.scale.x = -Math.abs({sprite_ref}.sprite.scale.x);")
2024
+ js.block_close()
2025
+
2026
+ # SMW-style jump physics
2027
+ js.comment("SMW-style jump: coyote time, variable height, asymmetric gravity")
2028
+
2029
+ # Initialize tracking variables if not present
2030
+ js.if_block(f'{sprite_ref}._coyoteFrames === undefined')
2031
+ js.line(f'{sprite_ref}._coyoteFrames = 0;')
2032
+ js.line(f'{sprite_ref}._jumpHeld = false;')
2033
+ js.line(f'{sprite_ref}._wasGrounded = false;')
2034
+ js.line(f'{sprite_ref}._jumpCooldown = 0;')
2035
+ js.block_close()
2036
+
2037
+ # Decrease jump cooldown
2038
+ js.if_block(f'{sprite_ref}._jumpCooldown > 0')
2039
+ js.line(f'{sprite_ref}._jumpCooldown--;')
2040
+ js.block_close()
2041
+
2042
+ # Check if grounded (small vertical velocity)
2043
+ js.const(f'_isGrounded_{sid_js}', f'{body_ref} && Math.abs({body_ref}.velocity.y) < 0.5')
2044
+ grounded_var = f'_isGrounded_{sid_js}'
2045
+
2046
+ # Update coyote time
2047
+ js.if_block(grounded_var)
2048
+ js.line(f'{sprite_ref}._coyoteFrames = {coyote_frames};')
2049
+ js.line(f'{sprite_ref}._wasGrounded = true;')
2050
+ js.else_if_block(f'{sprite_ref}._coyoteFrames > 0')
2051
+ js.line(f'{sprite_ref}._coyoteFrames--;')
2052
+ js.block_close()
2053
+
2054
+ # Can jump if (grounded OR within coyote time) AND cooldown expired
2055
+ js.const(f'_canJump_{sid_js}', f'({grounded_var} || {sprite_ref}._coyoteFrames > 0) && {sprite_ref}._jumpCooldown === 0')
2056
+ can_jump_var = f'_canJump_{sid_js}'
2057
+
2058
+ # Initiate jump
2059
+ js.if_block(f'{jump_just} && {body_ref} && {can_jump_var}')
2060
+ js.line(f"Matter.Body.setVelocity({body_ref}, {{ x: {body_ref}.velocity.x, y: -{jf} }});")
2061
+ js.line(f"{sprite_ref}._jumpHeld = true;")
2062
+ js.line(f"{sprite_ref}._coyoteFrames = 0;") # Consume coyote time
2063
+ js.line(f"{sprite_ref}._jumpCooldown = 8;") # Must wait 8 frames before next jump
2064
+ js.line(f"_gameEvents.emit('player.jump', {sprite_ref});")
2065
+ js.block_close()
2066
+
2067
+ # Hold jump for extra height (only while ascending)
2068
+ js.if_block(f'{jump_var} && {body_ref} && {sprite_ref}._jumpHeld && {body_ref}.velocity.y < 0')
2069
+ js.line(f"Matter.Body.setVelocity({body_ref}, {{ x: {body_ref}.velocity.x, y: {body_ref}.velocity.y - {jump_hold_boost} }});")
2070
+ js.block_close()
2071
+
2072
+ # Release jump button cancels hold boost
2073
+ js.if_block(f'!{jump_var}')
2074
+ js.line(f"{sprite_ref}._jumpHeld = false;")
2075
+ js.block_close()
2076
+
2077
+ # Asymmetric gravity: reduce gravity while ascending (floatier rise)
2078
+ if gravity_up is not None:
2079
+ gravity_up_val = js_number(gravity_up)
2080
+ js.comment("Asymmetric gravity: lighter when rising")
2081
+ js.if_block(f'{body_ref} && {body_ref}.velocity.y < 0')
2082
+ # Apply upward force to counteract some gravity (making ascent floatier)
2083
+ # The difference between normal gravity and gravity_up
2084
+ js.line(f"Matter.Body.applyForce({body_ref}, {body_ref}.position, {{ x: 0, y: -{body_ref}.mass * (mEngine.gravity.y - {gravity_up_val}) * 0.001 }});")
2085
+ js.block_close()
2086
+
2087
+ # Extra gravity when falling (snappier descent)
2088
+ if gravity_down is not None:
2089
+ gravity_down_val = js_number(gravity_down)
2090
+ js.comment("Asymmetric gravity: heavier when falling")
2091
+ js.if_block(f'{body_ref} && {body_ref}.velocity.y > 0')
2092
+ js.line(f"Matter.Body.applyForce({body_ref}, {body_ref}.position, {{ x: 0, y: {body_ref}.mass * ({gravity_down_val} - mEngine.gravity.y) * 0.001 }});")
2093
+ js.block_close()
2094
+
2095
+ # Cap fall speed (terminal velocity)
2096
+ js.if_block(f'{body_ref} && {body_ref}.velocity.y > {max_fall_speed}')
2097
+ js.line(f"Matter.Body.setVelocity({body_ref}, {{ x: {body_ref}.velocity.x, y: {max_fall_speed} }});")
2098
+ js.block_close()
2099
+
2100
+ # Stop horizontal movement when no keys pressed
2101
+ js.if_block(f'!{left_var} && !{right_var} && {body_ref}')
2102
+ js.line(f"Matter.Body.setVelocity({body_ref}, {{ x: 0, y: {body_ref}.velocity.y }});")
2103
+ js.line(f"_gameEvents.emit('player.stop', {sprite_ref});")
2104
+ js.else_block()
2105
+ js.line(f"_gameEvents.emit('player.walk', {sprite_ref});")
2106
+ js.block_close()
2107
+
2108
+ # Custom input actions (safe: reference by sanitized function name, no eval)
2109
+ for ci in self._custom_inputs:
2110
+ key = js_string(ci['key'])
2111
+ action = js_id(ci['action'])
2112
+ if ci['type'] == 'press':
2113
+ js.if_block(f"_justPressed[{key}] && typeof {action} === 'function'")
2114
+ else:
2115
+ js.if_block(f"_keys[{key}] && typeof {action} === 'function'")
2116
+ js.line(f"{action}();")
2117
+ js.block_close()
2118
+
2119
+ js.line('_clearJustPressed();')
2120
+
2121
+ # ------------------------------------------------------------------
2122
+ # Scene Start
2123
+ # ------------------------------------------------------------------
2124
+
2125
+ def _emit_scene_start(self, js: JsBuilder):
2126
+ # Register qg:event handlers
2127
+ self._emit_event_handlers(js)
2128
+
2129
+ # Play scene.start triggered sounds
2130
+ for s in self._sounds:
2131
+ if s.get('trigger') == 'scene.start':
2132
+ js.line(f"_playSound({js_string(s['id'])}, {{ volume: {js_number(s['volume'])}, loop: {js_bool(s['loop'])} }});")
2133
+ elif s.get('trigger'):
2134
+ # Wire to event bus
2135
+ trigger = js_string(s['trigger'])
2136
+ sid = js_string(s['id'])
2137
+ vol = js_number(s['volume'])
2138
+ loop = js_bool(s['loop'])
2139
+ js.line(f"_gameEvents.on({trigger}, () => _playSound({sid}, {{ volume: {vol}, loop: {loop} }}));")
2140
+
2141
+ # Activate trigger-based particles
2142
+ for p in self._particles:
2143
+ if p.get('trigger') == 'scene.start':
2144
+ js.line(f"_activateParticles({js_string(p['id'])});")
2145
+ elif p.get('trigger'):
2146
+ trigger = js_string(p['trigger'])
2147
+ pid = js_string(p['id'])
2148
+ js.line(f"_gameEvents.on({trigger}, () => _activateParticles({pid}));")
2149
+
2150
+ # Emit game-init event
2151
+ js.line("_gameEvents.emit('game-init', {});")
2152
+
2153
+ def _emit_event_handlers(self, js: JsBuilder):
2154
+ """Emit qg:event registrations."""
2155
+ if not self._events:
2156
+ return
2157
+
2158
+ js.comment('qg:event handlers')
2159
+ for evt in self._events:
2160
+ event_name = js_string(evt['name'])
2161
+ handler = js_id(evt['handler'])
2162
+ filter_tag = evt.get('filter_tag')
2163
+ filter_id = evt.get('filter_id')
2164
+
2165
+ # Build callback body
2166
+ js.line(f"_gameEvents.on({event_name}, (data) => {{")
2167
+ js.indent()
2168
+
2169
+ # Add filters if specified
2170
+ if filter_tag:
2171
+ js.if_block(f"data && data.tag !== {js_string(filter_tag)}")
2172
+ js.ret()
2173
+ js.block_close()
2174
+ if filter_id:
2175
+ # Check both sprite ID and object ID
2176
+ js.const('_evtId', "data && (data.id || (typeof data === 'object' && Object.keys(_sprites).find(k => _sprites[k] === data)))")
2177
+ js.if_block(f"_evtId !== {js_string(filter_id)}")
2178
+ js.ret()
2179
+ js.block_close()
2180
+
2181
+ # Call the handler function
2182
+ js.if_block(f"typeof {handler} === 'function'")
2183
+ js.line(f"{handler}(data);")
2184
+ js.block_close()
2185
+ js.dedent()
2186
+ js.line("});")
2187
+
2188
+ # ------------------------------------------------------------------
2189
+ # Spawner instances
2190
+ # ------------------------------------------------------------------
2191
+
2192
+ def _emit_spawner_instances(self, js: JsBuilder):
2193
+ if not self._spawners:
2194
+ js.comment('No spawners')
2195
+ return
2196
+ emit_spawn_system(js)
2197
+
2198
+ # Register prefabs used by spawners
2199
+ registered_prefabs = set()
2200
+ for sp in self._spawners:
2201
+ prefab_name = sp['prefab']
2202
+ if prefab_name not in registered_prefabs:
2203
+ registered_prefabs.add(prefab_name)
2204
+ prefab_node = self._prefabs.get(prefab_name)
2205
+ if prefab_node:
2206
+ # Extract sprite info from prefab
2207
+ for child in prefab_node.children:
2208
+ if isinstance(child, SpriteNode):
2209
+ config_parts = [
2210
+ f"width: {js_number(child.width or 20)}",
2211
+ f"height: {js_number(child.height or 20)}",
2212
+ ]
2213
+ if child.color:
2214
+ config_parts.append(f"color: {js_string(child.color)}")
2215
+ js.line(f"_registerPrefab({js_string(prefab_name)}, {{ {', '.join(config_parts)} }});")
2216
+ break
2217
+
2218
+ # Create spawners
2219
+ for sp in self._spawners:
2220
+ config_parts = [
2221
+ f"id: {js_string(sp['id'])}",
2222
+ f"prefab: {js_string(sp['prefab'])}",
2223
+ f"poolSize: {js_number(sp['pool_size'])}",
2224
+ f"x: {js_number(sp.get('x') or 0)}",
2225
+ f"y: {js_number(sp.get('y') or 0)}",
2226
+ ]
2227
+ if sp.get('interval') is not None:
2228
+ config_parts.append(f"interval: {js_number(sp['interval'])}")
2229
+ js.line(f"_createSpawner({{ {', '.join(config_parts)} }});")
2230
+
2231
+ # ------------------------------------------------------------------
2232
+ # Visual tilemaps
2233
+ # ------------------------------------------------------------------
2234
+
2235
+ def _emit_collision_grids(self, js: JsBuilder):
2236
+ """Emit tilemap collision grids as JS arrays for AABB-based resolution."""
2237
+ if not self._collision_grids:
2238
+ js.comment('No tilemap collision grids')
2239
+ return
2240
+ for cg in self._collision_grids:
2241
+ # Emit grid as compact JS array
2242
+ grid_rows = []
2243
+ for row in cg['grid']:
2244
+ if all(c == 0 for c in row):
2245
+ grid_rows.append('null') # null = empty row (optimization)
2246
+ else:
2247
+ grid_rows.append('[' + ','.join(str(c) for c in row) + ']')
2248
+ js.line(f"_collisionGrids.push({{ grid: [{','.join(grid_rows)}], tw: {cg['tw']}, th: {cg['th']} }});")
2249
+ js.blank()
2250
+
2251
+ def _emit_visual_tilemaps(self, js: JsBuilder):
2252
+ if not self._visual_tile_layers:
2253
+ js.comment('No visual tile layers')
2254
+ return
2255
+ _emitted_tilesets = set()
2256
+ for vt in self._visual_tile_layers:
2257
+ tilemap_id = js_id(vt['tilemap_id'])
2258
+ layer_name = js_id(vt['layer_name'])
2259
+ data_var = f'_tileData_{tilemap_id}_{layer_name}'
2260
+ tw = vt['tile_width']
2261
+ th = vt['tile_height']
2262
+ src = vt['src']
2263
+ tile_anims = vt.get('tile_animations', {})
2264
+
2265
+ # Emit tile data as 2D array
2266
+ rows = vt['data'].strip().split('\n')
2267
+ data_2d = []
2268
+ for row in rows:
2269
+ cells = [c.strip() for c in row.split(',') if c.strip()]
2270
+ data_2d.append('[' + ','.join(cells) + ']')
2271
+ js.const(data_var, '[' + ','.join(data_2d) + ']')
2272
+
2273
+ # Emit tile animations data if any
2274
+ if tile_anims:
2275
+ anims_js = {}
2276
+ for tile_id, anim_data in tile_anims.items():
2277
+ anims_js[str(tile_id)] = {
2278
+ 'frames': anim_data['frames'],
2279
+ 'speed': anim_data['speed']
2280
+ }
2281
+ js.const(f'_tileAnims_{tilemap_id}', json.dumps(anims_js))
2282
+
2283
+ # Emit JS loop to create sprites from tileset
2284
+ if src:
2285
+ if tilemap_id not in _emitted_tilesets:
2286
+ js.const(f'_tileset_{tilemap_id}', f"PIXI.Assets.get({js_string(src)})")
2287
+ ts_var = f'_tileset_{tilemap_id}'
2288
+ src_var = f'_tsSource_{tilemap_id}'
2289
+ js.const(src_var, f'{ts_var}.source || {ts_var}.baseTexture || {ts_var}')
2290
+ # Use source.width for PIXI 8 compatibility (texture.width may be 0)
2291
+ js.const(f'_tsCols_{tilemap_id}', f'Math.floor(({src_var}.width || {ts_var}.width || 256) / {tw})')
2292
+ _emitted_tilesets.add(tilemap_id)
2293
+
2294
+ # Helper function to create texture for a tile ID
2295
+ js.comment(f'Create tiles for layer {layer_name}')
2296
+ js.line(f'function _getTileTex_{tilemap_id}(tileId) {{')
2297
+ js.indent()
2298
+ js.const('col', f'tileId % _tsCols_{tilemap_id}')
2299
+ js.const('row', f'Math.floor(tileId / _tsCols_{tilemap_id})')
2300
+ js.const('rect', f'new PIXI.Rectangle(col * {tw}, row * {th}, {tw}, {th})')
2301
+ js.line(f'return new PIXI.Texture({{ source: _tsSource_{tilemap_id}, frame: rect }});')
2302
+ js.dedent()
2303
+ js.line('}')
2304
+
2305
+ js.for_range('_ty', '0', f'{data_var}.length - 1')
2306
+ js.for_range('_tx', '0', f'{data_var}[_ty].length - 1')
2307
+ js.const('_tileId', f'{data_var}[_ty][_tx]')
2308
+ js.if_block('_tileId > 0')
2309
+
2310
+ if src:
2311
+ if tile_anims:
2312
+ # Check if this tile has an animation (use String for key lookup)
2313
+ js.const('_animData', f'_tileAnims_{tilemap_id}[String(_tileId)]')
2314
+ js.line('let _tileSpr;')
2315
+ js.if_block('_animData')
2316
+ # Create AnimatedSprite for animated tiles
2317
+ js.const('_animTextures', f'_animData.frames.map(fid => _getTileTex_{tilemap_id}(fid))')
2318
+ js.assign('_tileSpr', 'new PIXI.AnimatedSprite(_animTextures)')
2319
+ js.line('_tileSpr.animationSpeed = _animData.speed;')
2320
+ js.line('_tileSpr.play();')
2321
+ js.else_block()
2322
+ # Regular static sprite
2323
+ js.assign('_tileSpr', f'new PIXI.Sprite(_getTileTex_{tilemap_id}(_tileId))')
2324
+ js.block_close()
2325
+ else:
2326
+ # No animations defined - all static
2327
+ js.const('_tileSpr', f'new PIXI.Sprite(_getTileTex_{tilemap_id}(_tileId))')
2328
+ else:
2329
+ js.const('_tileSpr', 'new PIXI.Graphics()')
2330
+ js.line(f"_tileSpr.rect(0, 0, {tw}, {th});")
2331
+ js.line("_tileSpr.fill({ color: 0x808080 });")
2332
+
2333
+ js.assign('_tileSpr.x', f'_tx * {tw}')
2334
+ js.assign('_tileSpr.y', f'_ty * {th}')
2335
+ js.line('_cameraContainer.addChild(_tileSpr);')
2336
+ js.block_close() # if tileId > 0
2337
+ js.block_close() # for _tx
2338
+ js.block_close() # for _ty
2339
+ js.blank()
2340
+
2341
+ # ------------------------------------------------------------------
2342
+ # Expression compilation
2343
+ # ------------------------------------------------------------------
2344
+
2345
+ def _compile_expression(self, expr: str) -> str:
2346
+ """Convert Quantum databinding expressions to JS safely."""
2347
+ if not expr:
2348
+ return '""'
2349
+ expr = expr.strip()
2350
+ # Handle {expr} databinding
2351
+ if expr.startswith('{') and expr.endswith('}'):
2352
+ inner = expr[1:-1]
2353
+ inner = inner.replace('&lt;', '<').replace('&gt;', '>').replace('&amp;', '&')
2354
+ # Convert self. to this. for JavaScript
2355
+ inner = re.sub(r'\bself\.', 'this.', inner)
2356
+ # Validate: only allow safe JS expressions (identifiers, math, dots, parens, arrays, ternary)
2357
+ if re.match(r'^[a-zA-Z_$0-9][a-zA-Z0-9_$.\s+\-*/()%<>=!&|,\[\]?:\'"]*$', inner):
2358
+ return inner
2359
+ return json.dumps(expr) # escape unsafe expressions
2360
+ # Handle XML-escaped operators
2361
+ expr = expr.replace('&lt;', '<').replace('&gt;', '>').replace('&amp;', '&')
2362
+ # Convert self. to this. for JavaScript
2363
+ expr = re.sub(r'\bself\.', 'this.', expr)
2364
+ if expr.startswith('"') or expr.startswith("'") or is_number(expr) or expr in ('true', 'false'):
2365
+ return expr
2366
+ return json.dumps(expr)
2367
+
2368
+
2369
+ def html_escape_title(title: str) -> str:
2370
+ """Escape a title for safe HTML embedding."""
2371
+ return title.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')