quantum-framework 0.9.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 (262) hide show
  1. quantum/__init__.py +1 -0
  2. quantum/cli/__init__.py +2 -0
  3. quantum/cli/commands/__init__.py +15 -0
  4. quantum/cli/commands/build.py +417 -0
  5. quantum/cli/commands/dev.py +185 -0
  6. quantum/cli/commands/docs.py +329 -0
  7. quantum/cli/commands/lint.py +523 -0
  8. quantum/cli/commands/migrate.py +527 -0
  9. quantum/cli/commands/new.py +622 -0
  10. quantum/cli/commands/serve.py +190 -0
  11. quantum/cli/commands/test.py +193 -0
  12. quantum/cli/deploy.py +810 -0
  13. quantum/cli/hot_reload.py +951 -0
  14. quantum/cli/jobs.py +356 -0
  15. quantum/cli/mq.py +582 -0
  16. quantum/cli/pkg.py +390 -0
  17. quantum/cli/runner.py +547 -0
  18. quantum/cli/server_process.py +159 -0
  19. quantum/cli/utils.py +334 -0
  20. quantum/compiler/__init__.py +30 -0
  21. quantum/compiler/base_generator.py +367 -0
  22. quantum/compiler/cli.py +295 -0
  23. quantum/compiler/expression_transformer.py +444 -0
  24. quantum/compiler/javascript/__init__.py +10 -0
  25. quantum/compiler/javascript/generator.py +659 -0
  26. quantum/compiler/optimizer.py +270 -0
  27. quantum/compiler/python/__init__.py +10 -0
  28. quantum/compiler/python/generator.py +883 -0
  29. quantum/compiler/python/runtime.py +863 -0
  30. quantum/compiler/transpiler.py +330 -0
  31. quantum/core/__init__.py +3 -0
  32. quantum/core/ast_nodes.py +2611 -0
  33. quantum/core/expression_diagnostics.py +87 -0
  34. quantum/core/expression_stdlib.py +148 -0
  35. quantum/core/expressions.py +591 -0
  36. quantum/core/features/agents/src/__init__.py +30 -0
  37. quantum/core/features/agents/src/ast_node.py +540 -0
  38. quantum/core/features/conditionals/src/__init__.py +8 -0
  39. quantum/core/features/conditionals/src/ast_node.py +68 -0
  40. quantum/core/features/data_fetching/src/__init__.py +21 -0
  41. quantum/core/features/data_fetching/src/ast_node.py +312 -0
  42. quantum/core/features/data_fetching/src/desktop_adapter.py +351 -0
  43. quantum/core/features/data_fetching/src/html_adapter.py +474 -0
  44. quantum/core/features/data_fetching/src/parser.py +225 -0
  45. quantum/core/features/data_import/src/__init__.py +0 -0
  46. quantum/core/features/data_import/src/ast_node.py +291 -0
  47. quantum/core/features/data_import/src/runtime.py +538 -0
  48. quantum/core/features/dump/src/__init__.py +12 -0
  49. quantum/core/features/dump/src/ast_node.py +106 -0
  50. quantum/core/features/dump/src/parser.py +61 -0
  51. quantum/core/features/dump/src/runtime.py +246 -0
  52. quantum/core/features/functions/src/__init__.py +8 -0
  53. quantum/core/features/functions/src/ast_node.py +149 -0
  54. quantum/core/features/game_engine_2d/src/__init__.py +19 -0
  55. quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
  56. quantum/core/features/game_engine_2d/src/parser.py +983 -0
  57. quantum/core/features/invocation/src/__init__.py +0 -0
  58. quantum/core/features/invocation/src/ast_node.py +146 -0
  59. quantum/core/features/invocation/src/runtime.py +327 -0
  60. quantum/core/features/knowledge_base/src/__init__.py +6 -0
  61. quantum/core/features/knowledge_base/src/ast_node.py +113 -0
  62. quantum/core/features/knowledge_base/src/parser.py +82 -0
  63. quantum/core/features/logging/src/__init__.py +12 -0
  64. quantum/core/features/logging/src/ast_node.py +111 -0
  65. quantum/core/features/logging/src/parser.py +50 -0
  66. quantum/core/features/logging/src/runtime.py +190 -0
  67. quantum/core/features/loops/src/__init__.py +8 -0
  68. quantum/core/features/loops/src/ast_node.py +60 -0
  69. quantum/core/features/query/src/__init__.py +0 -0
  70. quantum/core/features/query/src/database_service.py +322 -0
  71. quantum/core/features/query/src/query_validators.py +20 -0
  72. quantum/core/features/state_management/src/__init__.py +11 -0
  73. quantum/core/features/state_management/src/ast_node.py +228 -0
  74. quantum/core/features/terminal_engine/src/__init__.py +21 -0
  75. quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
  76. quantum/core/features/terminal_engine/src/parser.py +361 -0
  77. quantum/core/features/testing_engine/src/__init__.py +41 -0
  78. quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
  79. quantum/core/features/testing_engine/src/parser.py +604 -0
  80. quantum/core/features/theming/src/__init__.py +48 -0
  81. quantum/core/features/theming/src/ast_node.py +137 -0
  82. quantum/core/features/theming/src/presets.py +405 -0
  83. quantum/core/features/ui_engine/src/__init__.py +20 -0
  84. quantum/core/features/ui_engine/src/ast_nodes.py +1854 -0
  85. quantum/core/features/ui_engine/src/parser.py +1106 -0
  86. quantum/core/features/websocket/src/__init__.py +24 -0
  87. quantum/core/features/websocket/src/ast_node.py +247 -0
  88. quantum/core/html_compat.py +299 -0
  89. quantum/core/parser.py +1235 -0
  90. quantum/core/parser_registry.py +213 -0
  91. quantum/core/parsers/__init__.py +76 -0
  92. quantum/core/parsers/ai/__init__.py +12 -0
  93. quantum/core/parsers/ai/agent_parser.py +106 -0
  94. quantum/core/parsers/ai/knowledge_parser.py +86 -0
  95. quantum/core/parsers/ai/llm_parser.py +78 -0
  96. quantum/core/parsers/ai/team_parser.py +88 -0
  97. quantum/core/parsers/base.py +322 -0
  98. quantum/core/parsers/composition/__init__.py +10 -0
  99. quantum/core/parsers/composition/import_parser.py +50 -0
  100. quantum/core/parsers/composition/slot_parser.py +48 -0
  101. quantum/core/parsers/control_flow/__init__.py +11 -0
  102. quantum/core/parsers/control_flow/if_parser.py +68 -0
  103. quantum/core/parsers/control_flow/loop_parser.py +105 -0
  104. quantum/core/parsers/control_flow/set_parser.py +89 -0
  105. quantum/core/parsers/data/__init__.py +12 -0
  106. quantum/core/parsers/data/data_parser.py +217 -0
  107. quantum/core/parsers/data/invoke_parser.py +116 -0
  108. quantum/core/parsers/data/query_parser.py +188 -0
  109. quantum/core/parsers/data/transaction_parser.py +77 -0
  110. quantum/core/parsers/events/__init__.py +9 -0
  111. quantum/core/parsers/events/dispatch_event_parser.py +44 -0
  112. quantum/core/parsers/forms/__init__.py +11 -0
  113. quantum/core/parsers/forms/action_parser.py +54 -0
  114. quantum/core/parsers/forms/flash_parser.py +34 -0
  115. quantum/core/parsers/forms/redirect_parser.py +33 -0
  116. quantum/core/parsers/functions/__init__.py +11 -0
  117. quantum/core/parsers/functions/function_parser.py +137 -0
  118. quantum/core/parsers/functions/param_parser.py +66 -0
  119. quantum/core/parsers/functions/return_parser.py +31 -0
  120. quantum/core/parsers/html/__init__.py +10 -0
  121. quantum/core/parsers/html/component_call_parser.py +130 -0
  122. quantum/core/parsers/html/html_parser.py +115 -0
  123. quantum/core/parsers/jobs/__init__.py +11 -0
  124. quantum/core/parsers/jobs/job_parser.py +71 -0
  125. quantum/core/parsers/jobs/schedule_parser.py +62 -0
  126. quantum/core/parsers/jobs/thread_parser.py +57 -0
  127. quantum/core/parsers/messaging/__init__.py +17 -0
  128. quantum/core/parsers/messaging/message_ack_parser.py +30 -0
  129. quantum/core/parsers/messaging/message_nack_parser.py +30 -0
  130. quantum/core/parsers/messaging/message_parser.py +114 -0
  131. quantum/core/parsers/messaging/queue_parser.py +61 -0
  132. quantum/core/parsers/messaging/websocket_parser.py +121 -0
  133. quantum/core/parsers/persistence/__init__.py +9 -0
  134. quantum/core/parsers/persistence/persist_parser.py +64 -0
  135. quantum/core/parsers/routing/__init__.py +9 -0
  136. quantum/core/parsers/routing/route_parser.py +41 -0
  137. quantum/core/parsers/scripting/__init__.py +12 -0
  138. quantum/core/parsers/scripting/pyclass_parser.py +62 -0
  139. quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
  140. quantum/core/parsers/scripting/pyimport_parser.py +52 -0
  141. quantum/core/parsers/scripting/python_parser.py +49 -0
  142. quantum/core/parsers/services/__init__.py +12 -0
  143. quantum/core/parsers/services/dump_parser.py +52 -0
  144. quantum/core/parsers/services/file_parser.py +48 -0
  145. quantum/core/parsers/services/log_parser.py +46 -0
  146. quantum/core/parsers/services/mail_parser.py +65 -0
  147. quantum/core/tiers.py +82 -0
  148. quantum/packages/__init__.py +28 -0
  149. quantum/packages/manager.py +413 -0
  150. quantum/packages/manifest.py +351 -0
  151. quantum/packages/registry.py +399 -0
  152. quantum/packages/resolver.py +336 -0
  153. quantum/plugins/__init__.py +33 -0
  154. quantum/plugins/hooks.py +329 -0
  155. quantum/plugins/loader.py +479 -0
  156. quantum/plugins/manifest.py +336 -0
  157. quantum/plugins/registry.py +371 -0
  158. quantum/runtime/__init__.py +28 -0
  159. quantum/runtime/action_handler.py +443 -0
  160. quantum/runtime/adapters/__init__.py +88 -0
  161. quantum/runtime/adapters/memory_adapter.py +690 -0
  162. quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
  163. quantum/runtime/adapters/redis_adapter.py +582 -0
  164. quantum/runtime/adapters/sqlite_adapter.py +414 -0
  165. quantum/runtime/agent_service.py +1133 -0
  166. quantum/runtime/api_server.py +86 -0
  167. quantum/runtime/ast_cache.py +506 -0
  168. quantum/runtime/auth_service.py +267 -0
  169. quantum/runtime/component.py +990 -0
  170. quantum/runtime/component_composer.py +319 -0
  171. quantum/runtime/component_resolver.py +174 -0
  172. quantum/runtime/database_service.py +598 -0
  173. quantum/runtime/email_service.py +162 -0
  174. quantum/runtime/error_handler.py +295 -0
  175. quantum/runtime/execution_context.py +286 -0
  176. quantum/runtime/executor_registry.py +171 -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 +262 -0
  184. quantum/runtime/executors/control_flow/__init__.py +11 -0
  185. quantum/runtime/executors/control_flow/if_executor.py +93 -0
  186. quantum/runtime/executors/control_flow/loop_executor.py +307 -0
  187. quantum/runtime/executors/control_flow/set_executor.py +412 -0
  188. quantum/runtime/executors/data/__init__.py +12 -0
  189. quantum/runtime/executors/data/data_executor.py +145 -0
  190. quantum/runtime/executors/data/invoke_executor.py +176 -0
  191. quantum/runtime/executors/data/query_executor.py +256 -0
  192. quantum/runtime/executors/data/transaction_executor.py +91 -0
  193. quantum/runtime/executors/jobs/__init__.py +11 -0
  194. quantum/runtime/executors/jobs/job_executor.py +190 -0
  195. quantum/runtime/executors/jobs/schedule_executor.py +132 -0
  196. quantum/runtime/executors/jobs/thread_executor.py +127 -0
  197. quantum/runtime/executors/messaging/__init__.py +17 -0
  198. quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
  199. quantum/runtime/executors/messaging/message_executor.py +174 -0
  200. quantum/runtime/executors/messaging/queue_executor.py +103 -0
  201. quantum/runtime/executors/messaging/websocket_executor.py +197 -0
  202. quantum/runtime/executors/scripting/__init__.py +11 -0
  203. quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
  204. quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
  205. quantum/runtime/executors/scripting/python_executor.py +249 -0
  206. quantum/runtime/executors/services/__init__.py +12 -0
  207. quantum/runtime/executors/services/dump_executor.py +72 -0
  208. quantum/runtime/executors/services/file_executor.py +89 -0
  209. quantum/runtime/executors/services/log_executor.py +77 -0
  210. quantum/runtime/executors/services/mail_executor.py +81 -0
  211. quantum/runtime/expression_cache.py +498 -0
  212. quantum/runtime/file_upload_service.py +326 -0
  213. quantum/runtime/function_registry.py +118 -0
  214. quantum/runtime/game_builder.py +166 -0
  215. quantum/runtime/game_code_generator.py +2371 -0
  216. quantum/runtime/game_templates.py +2006 -0
  217. quantum/runtime/godot_code_generator.py +4681 -0
  218. quantum/runtime/godot_templates.py +1449 -0
  219. quantum/runtime/job_executor.py +1599 -0
  220. quantum/runtime/knowledge_service.py +500 -0
  221. quantum/runtime/llm_cache.py +100 -0
  222. quantum/runtime/llm_providers.py +704 -0
  223. quantum/runtime/llm_service.py +287 -0
  224. quantum/runtime/logging_setup.py +140 -0
  225. quantum/runtime/message_broker.py +364 -0
  226. quantum/runtime/message_queue_service.py +571 -0
  227. quantum/runtime/param_validation.py +184 -0
  228. quantum/runtime/pypy_compat.py +315 -0
  229. quantum/runtime/python_bridge.py +698 -0
  230. quantum/runtime/query_validators.py +304 -0
  231. quantum/runtime/renderer.py +733 -0
  232. quantum/runtime/service_container.py +444 -0
  233. quantum/runtime/terminal_builder.py +76 -0
  234. quantum/runtime/terminal_code_generator.py +607 -0
  235. quantum/runtime/terminal_templates.py +243 -0
  236. quantum/runtime/testing_builder.py +77 -0
  237. quantum/runtime/testing_code_generator.py +833 -0
  238. quantum/runtime/testing_templates.py +85 -0
  239. quantum/runtime/ui_builder.py +188 -0
  240. quantum/runtime/ui_desktop_adapter.py +1730 -0
  241. quantum/runtime/ui_desktop_templates.py +307 -0
  242. quantum/runtime/ui_html_adapter.py +2691 -0
  243. quantum/runtime/ui_html_templates.py +2297 -0
  244. quantum/runtime/ui_mobile_adapter.py +1832 -0
  245. quantum/runtime/ui_mobile_templates.py +1003 -0
  246. quantum/runtime/ui_textual_adapter.py +1866 -0
  247. quantum/runtime/ui_textual_templates.py +45 -0
  248. quantum/runtime/ui_tokens.py +465 -0
  249. quantum/runtime/ui_validator.py +365 -0
  250. quantum/runtime/validators.py +256 -0
  251. quantum/runtime/web_server.py +1766 -0
  252. quantum/runtime/websocket_adapter.py +501 -0
  253. quantum/runtime/websocket_service.py +585 -0
  254. quantum/runtime/websocket_transport.py +289 -0
  255. quantum/runtime/wsgi.py +101 -0
  256. quantum/utils/__init__.py +1 -0
  257. quantum_framework-0.9.0.dist-info/METADATA +244 -0
  258. quantum_framework-0.9.0.dist-info/RECORD +262 -0
  259. quantum_framework-0.9.0.dist-info/WHEEL +5 -0
  260. quantum_framework-0.9.0.dist-info/entry_points.txt +2 -0
  261. quantum_framework-0.9.0.dist-info/licenses/LICENSE +21 -0
  262. quantum_framework-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4681 @@
1
+ """
2
+ Godot 4 - Code Generator
3
+
4
+ Transforms Game AST nodes into Godot 4 project files (.tscn, .gd, project.godot).
5
+ This is the Godot backend equivalent of game_code_generator.py (PIXI+Matter.js).
6
+
7
+ AST and Parser are unchanged — only the code generation backend differs.
8
+
9
+ Pipeline:
10
+ .q → Parser → AST → GodotCodeGenerator → .tscn + .gd files → Godot project directory
11
+ """
12
+
13
+ import os
14
+ import json
15
+ import shutil
16
+ from pathlib import Path
17
+ from typing import List, Dict, Any, Optional, Tuple
18
+
19
+ from quantum.core.features.game_engine_2d.src.ast_nodes import (
20
+ SceneNode, SpriteNode, PhysicsNode, ColliderNode, AnimationNode,
21
+ CameraNode, InputNode, SoundNode, ParticleNode, TimerNode,
22
+ SpawnNode, HudNode, TweenNode, TilemapNode, TilemapLayerNode,
23
+ BehaviorNode, UseNode, PrefabNode, InstanceNode, GroupNode,
24
+ StateMachineNode, StateNode, TransitionNode, RawCodeNode,
25
+ ClickableNode, EventNode, OnCollisionNode, TileAnimationNode,
26
+ HudTileNode, HudCounterNode, HudCollectionNode,
27
+ HudSlotNode, HudOptionNode, HudBehaviorNode, HudActionNode,
28
+ SceneTransitionNode, PersistentNode, MapNodeDef, MapPathNode,
29
+ EventActionNode, CollisionLayerDef,
30
+ EnemyNode, EnemyAiNode, EnemyDefeatNode, EnemyOnHitNode, EnemyOnKillNode,
31
+ )
32
+ from quantum.core.ast_nodes import QuantumNode, HTMLNode, TextNode
33
+ from quantum.core.features.conditionals.src.ast_node import IfNode
34
+ from quantum.core.features.loops.src.ast_node import LoopNode
35
+ from quantum.core.features.state_management.src.ast_node import SetNode
36
+ from quantum.core.features.functions.src.ast_node import FunctionNode
37
+
38
+ from quantum.runtime.godot_templates import (
39
+ GdScriptBuilder, TscnBuilder,
40
+ QUANTUM_SCALE, q2g, q2g_int, q2g_gravity, q2g_speed, q2g_jump,
41
+ gd_string, gd_bool, gd_vector2, gd_vector2i, gd_color,
42
+ EASING_MAP, TRANS_MAP,
43
+ build_project_godot, build_export_presets,
44
+ QUANTUM_BRIDGE_GD, QUANTUM_EVENT_BUS_GD,
45
+ PLAYER_CONTROLLER_GD, SPIN_JUMP_SECTION, CAMERA_FOLLOW_GD, PATROL_AI_GD, HUD_MANAGER_GD, SPRITE_ANIMATOR_GD,
46
+ SCENE_MANAGER_GD, MAP_CONTROLLER_GD, GAME_OVER_SCREEN_GD, TILEMAP_LOADER_GD,
47
+ # SNES constants
48
+ SNES_WIDTH, SNES_HEIGHT, SNES_WINDOW_SCALE,
49
+ SMW_GRAVITY, SMW_GRAVITY_RISING, SMW_GRAVITY_FALLING,
50
+ SMW_WALK_SPEED, SMW_JUMP_VELOCITY, SMW_MAX_FALL,
51
+ SMW_JUMP_HOLD_BOOST, SMW_COYOTE_FRAMES,
52
+ )
53
+
54
+
55
+ class GodotCodeGenerator:
56
+ """Generates a Godot 4 project directory from a game AST."""
57
+
58
+ def __init__(self, source_dir: str = None):
59
+ self._source_dir = source_dir
60
+ self._sprites: List[Dict] = []
61
+ self._behaviors: Dict[str, BehaviorNode] = {}
62
+ self._prefabs: Dict[str, PrefabNode] = {}
63
+ self._state_vars: List[Dict] = []
64
+ self._functions: List[FunctionNode] = []
65
+ self._sounds: List[Dict] = []
66
+ self._particles: List[Dict] = []
67
+ self._timers: List[Dict] = []
68
+ self._tweens: List[Dict] = []
69
+ self._huds: List[Dict] = []
70
+ self._custom_inputs: List[Dict] = []
71
+ self._events: List[Dict] = []
72
+ self._camera: Optional[Dict] = None
73
+ self._physics: Optional[Dict] = None
74
+ self._assets: set = set()
75
+ self._sprite_counter: int = 0
76
+ self._scene_width: int = 800
77
+ self._scene_height: int = 600
78
+ self._viewport_width: int = SNES_WIDTH
79
+ self._viewport_height: int = SNES_HEIGHT
80
+ self._spawners: List[Dict] = []
81
+ self._tilemaps: List[Dict] = []
82
+ self._instances: List[Dict] = []
83
+ self._groups: List[Dict] = []
84
+ self._map_nodes: List[Dict] = []
85
+ self._map_paths: List[Dict] = []
86
+ self._enemies: List[EnemyNode] = []
87
+ self._output_dir: Optional[Path] = None
88
+
89
+ # ------------------------------------------------------------------
90
+ # Public API
91
+ # ------------------------------------------------------------------
92
+
93
+ def generate(self, scene: SceneNode, behaviors: List[BehaviorNode] = None,
94
+ prefabs: List[PrefabNode] = None,
95
+ enemies: List[EnemyNode] = None,
96
+ output_dir: str = 'output',
97
+ project_name: str = 'QuantumGame') -> str:
98
+ """Generate a complete Godot 4 project directory from a SceneNode.
99
+
100
+ Returns the output directory path.
101
+ """
102
+ self._scene_width = scene.width
103
+ self._scene_height = scene.height
104
+ self._viewport_width = scene.viewport_width or SNES_WIDTH
105
+ self._viewport_height = scene.viewport_height or SNES_HEIGHT
106
+
107
+ for b in (behaviors or []):
108
+ self._behaviors[b.name] = b
109
+ for p in (prefabs or []):
110
+ self._prefabs[p.name] = p
111
+ for e in (enemies or []):
112
+ self._enemies.append(e)
113
+
114
+ # Walk the AST
115
+ self._process_scene_children(scene.children)
116
+
117
+ # Build output directory
118
+ out = Path(output_dir)
119
+ self._output_dir = out
120
+ out.mkdir(parents=True, exist_ok=True)
121
+ scripts_dir = out / 'scripts'
122
+ scripts_dir.mkdir(exist_ok=True)
123
+ prefabs_dir = out / 'prefabs'
124
+ prefabs_dir.mkdir(exist_ok=True)
125
+ assets_dir = out / 'assets'
126
+ assets_dir.mkdir(exist_ok=True)
127
+
128
+ # 1. project.godot
129
+ self._write_project_godot(out, scene, project_name)
130
+
131
+ # 2. Autoload scripts
132
+ self._write_autoload_scripts(scripts_dir)
133
+
134
+ # 3. Main scene .tscn + script
135
+ self._write_main_scene(out, scripts_dir, scene)
136
+
137
+ # 4. Prefab scenes
138
+ self._write_prefab_scenes(prefabs_dir, scripts_dir)
139
+
140
+ # 5. Player controller (if needed)
141
+ self._write_player_controller(scripts_dir)
142
+
143
+ # 6. Camera script (if needed)
144
+ self._write_camera_script(scripts_dir)
145
+
146
+ # 7. Enemy/AI scripts
147
+ self._write_ai_scripts(scripts_dir)
148
+
149
+ # 8. HUD script (if needed)
150
+ self._write_hud_script(scripts_dir)
151
+
152
+ # 9. Game over screen (if death_sequence="smw")
153
+ self._write_game_over_screen(scripts_dir, scene)
154
+
155
+ # 10. Tilemap assets + loader (if data-src)
156
+ self._write_tilemap_assets(out)
157
+ self._write_tilemap_loader(scripts_dir)
158
+
159
+ # 11. Export presets
160
+ self._write_export_presets(out)
161
+
162
+ return str(out.resolve())
163
+
164
+ def generate_multi(self, scenes: List[SceneNode], initial: str,
165
+ behaviors: List[BehaviorNode] = None,
166
+ prefabs: List[PrefabNode] = None,
167
+ enemies: List[EnemyNode] = None,
168
+ persistent: List = None,
169
+ output_dir: str = 'output',
170
+ project_name: str = 'QuantumGame') -> str:
171
+ """Generate Godot project with multiple scenes.
172
+
173
+ Each scene becomes a separate .tscn + .gd pair in the scenes/ directory.
174
+ SceneManager autoload handles transitions and persistent state.
175
+ """
176
+ if not scenes:
177
+ return self.generate(SceneNode('empty'), behaviors, prefabs, enemies, output_dir, project_name)
178
+
179
+ # If only one scene, use single-scene generation (backward compat)
180
+ if len(scenes) == 1:
181
+ return self.generate(scenes[0], behaviors, prefabs, enemies, output_dir, project_name)
182
+
183
+ # Find initial scene
184
+ initial_scene = scenes[0]
185
+ for sc in scenes:
186
+ if getattr(sc, 'initial', False):
187
+ initial_scene = sc
188
+ break
189
+ if sc.name == initial:
190
+ initial_scene = sc
191
+
192
+ # Set up shared state
193
+ for b in (behaviors or []):
194
+ self._behaviors[b.name] = b
195
+ for p in (prefabs or []):
196
+ self._prefabs[p.name] = p
197
+ for e in (enemies or []):
198
+ self._enemies.append(e)
199
+
200
+ # Build output directory structure
201
+ out = Path(output_dir)
202
+ self._output_dir = out
203
+ out.mkdir(parents=True, exist_ok=True)
204
+ scenes_dir = out / 'scenes'
205
+ scenes_dir.mkdir(exist_ok=True)
206
+ scripts_dir = out / 'scripts'
207
+ scripts_dir.mkdir(exist_ok=True)
208
+ prefabs_dir = out / 'prefabs'
209
+ prefabs_dir.mkdir(exist_ok=True)
210
+ assets_dir = out / 'assets'
211
+ assets_dir.mkdir(exist_ok=True)
212
+
213
+ # 1. project.godot — main_scene points to initial scene in scenes/
214
+ self._write_multi_project_godot(out, initial_scene, scenes, project_name)
215
+
216
+ # 2. Autoload scripts (bridge + event bus + scene manager)
217
+ self._write_autoload_scripts(scripts_dir)
218
+ (scripts_dir / 'scene_manager.gd').write_text(
219
+ self._build_scene_manager_script(persistent or []),
220
+ encoding='utf-8'
221
+ )
222
+
223
+ # 3. Generate each scene as scenes/{name}.tscn + scripts/scene_{name}.gd
224
+ all_has_player = False
225
+ all_has_camera = False
226
+ all_has_patrol = False
227
+ for scene in scenes:
228
+ self._reset_scene_state()
229
+ self._scene_width = scene.width
230
+ self._scene_height = scene.height
231
+ self._viewport_width = scene.viewport_width or SNES_WIDTH
232
+ self._viewport_height = scene.viewport_height or SNES_HEIGHT
233
+
234
+ self._process_scene_children(scene.children)
235
+
236
+ # Generate .tscn + .gd for this scene
237
+ self._write_scene_file(scenes_dir, scripts_dir, scene)
238
+
239
+ if any(s.get('controls') for s in self._sprites):
240
+ all_has_player = True
241
+ if self._camera:
242
+ all_has_camera = True
243
+ if any(s.get('tag') in ('enemy',) for s in self._sprites):
244
+ all_has_patrol = True
245
+
246
+ # 4. Shared scripts
247
+ if all_has_player:
248
+ self._write_player_controller(scripts_dir)
249
+ if all_has_camera:
250
+ self._write_camera_script(scripts_dir)
251
+ if all_has_patrol:
252
+ self._write_ai_scripts(scripts_dir)
253
+
254
+ # 5. Shared prefab scenes
255
+ self._write_prefab_scenes(prefabs_dir, scripts_dir)
256
+
257
+ # 6. Tilemap assets + loader (if any scene uses data-src)
258
+ self._write_tilemap_assets(out)
259
+ self._write_tilemap_loader(scripts_dir)
260
+
261
+ # 7. Export presets
262
+ self._write_export_presets(out)
263
+
264
+ # 8. Copy referenced assets (sprites, sounds) to output
265
+ self._copy_assets(out)
266
+
267
+ return str(out.resolve())
268
+
269
+ def _reset_scene_state(self):
270
+ """Reset per-scene state for multi-scene generation."""
271
+ self._sprites = []
272
+ self._state_vars = []
273
+ self._functions = []
274
+ self._sounds = []
275
+ self._particles = []
276
+ self._timers = []
277
+ self._tweens = []
278
+ self._huds = []
279
+ self._custom_inputs = []
280
+ self._events = []
281
+ self._camera = None
282
+ self._physics = None
283
+ self._spawners = []
284
+ self._tilemaps = []
285
+ self._instances = []
286
+ self._groups = []
287
+ self._map_nodes = []
288
+ self._map_paths = []
289
+ self._sprite_counter = 0
290
+
291
+ def _write_multi_project_godot(self, out: Path, initial_scene: SceneNode,
292
+ scenes: List[SceneNode], project_name: str):
293
+ """Generate project.godot for multi-scene project."""
294
+ gravity_y = SMW_GRAVITY
295
+
296
+ # Collect input maps from all scenes
297
+ # Reset + process all scenes to build comprehensive input map
298
+ saved_sprites = self._sprites
299
+ all_inputs = {}
300
+ for scene in scenes:
301
+ self._sprites = []
302
+ self._custom_inputs = []
303
+ self._process_scene_children(scene.children)
304
+ scene_map = self._build_input_map()
305
+ all_inputs.update(scene_map)
306
+ self._sprites = saved_sprites
307
+
308
+ scene_name = _sanitize_name(initial_scene.name)
309
+ config = {
310
+ 'name': project_name,
311
+ 'main_scene': f'scenes/{scene_name}.tscn',
312
+ 'viewport_width': initial_scene.viewport_width or SNES_WIDTH,
313
+ 'viewport_height': initial_scene.viewport_height or SNES_HEIGHT,
314
+ 'gravity': gravity_y,
315
+ 'input_map': all_inputs,
316
+ 'autoloads': {
317
+ 'QuantumBridge': 'scripts/quantum_bridge.gd',
318
+ 'QuantumEventBus': 'scripts/quantum_event_bus.gd',
319
+ 'SceneManager': 'scripts/scene_manager.gd',
320
+ },
321
+ }
322
+ content = build_project_godot(config)
323
+ (out / 'project.godot').write_text(content, encoding='utf-8')
324
+
325
+ def _build_scene_manager_script(self, persistent_nodes: List) -> str:
326
+ """Build SceneManager script with persistent state initialization."""
327
+ script = SCENE_MANAGER_GD
328
+
329
+ # If there are persistent nodes, add initial state setup
330
+ if persistent_nodes:
331
+ init_lines = []
332
+ for pnode in persistent_nodes:
333
+ if isinstance(pnode, PersistentNode):
334
+ for child in pnode.children:
335
+ if isinstance(child, SetNode):
336
+ name = child.name
337
+ value = child.value
338
+ var_type = getattr(child, 'type', 'string')
339
+ if var_type == 'integer':
340
+ init_lines.append(f'\tpersistent_state["{name}"] = {value}')
341
+ elif var_type == 'float':
342
+ init_lines.append(f'\tpersistent_state["{name}"] = {value}')
343
+ elif var_type == 'boolean':
344
+ gd_val = 'true' if value.lower() in ('true', '1', 'yes') else 'false'
345
+ init_lines.append(f'\tpersistent_state["{name}"] = {gd_val}')
346
+ else:
347
+ init_lines.append(f'\tpersistent_state["{name}"] = "{value}"')
348
+
349
+ if init_lines:
350
+ # Insert initialization into _ready()
351
+ init_block = '\n'.join(init_lines)
352
+ script = script.replace(
353
+ '\t_setup_transition_layer()',
354
+ f'\t_setup_transition_layer()\n\t_init_persistent_state()\n'
355
+ )
356
+ script = script.rstrip()
357
+ script += f'\n\nfunc _init_persistent_state():\n{init_block}\n'
358
+
359
+ return script
360
+
361
+ def _write_scene_file(self, scenes_dir: Path, scripts_dir: Path, scene: SceneNode):
362
+ """Generate a single scene .tscn + .gd for multi-scene mode."""
363
+ scene_name = _sanitize_name(scene.name)
364
+ script_path = f'scripts/scene_{scene_name}.gd'
365
+
366
+ tscn = TscnBuilder()
367
+
368
+ # Determine root node type based on scene type
369
+ root_type = 'Node2D'
370
+
371
+ # Root node with script
372
+ tscn.add_node_with_script(
373
+ _pascal_case(scene_name), root_type,
374
+ # Script path is relative to res:// (project root)
375
+ script_path=f'scripts/scene_{scene_name}.gd'
376
+ )
377
+
378
+ # Background color
379
+ bg = scene.background or '#000000'
380
+ tscn.add_node('Background', 'ColorRect', parent='.', properties={
381
+ 'offset_right': float(scene.width),
382
+ 'offset_bottom': float(scene.height),
383
+ 'color': gd_color(bg),
384
+ })
385
+
386
+ # Sprites
387
+ for sprite in self._sprites:
388
+ self._add_sprite_to_tscn(tscn, sprite, parent='.')
389
+
390
+ # Tilemaps
391
+ for tilemap in self._tilemaps:
392
+ self._add_tilemap_to_tscn(tscn, tilemap, parent='.')
393
+
394
+ # Camera
395
+ if self._camera:
396
+ self._add_camera_to_tscn(tscn)
397
+
398
+ # Sounds
399
+ if self._sounds:
400
+ tscn.add_node('Sounds', 'Node', parent='.')
401
+ for sound in self._sounds:
402
+ props = {}
403
+ if sound['volume'] != 1.0:
404
+ import math
405
+ props['volume_db'] = 20 * math.log10(max(sound['volume'], 0.001))
406
+ src = sound['src']
407
+ if src:
408
+ res_id = tscn.add_ext_resource('AudioStream', _asset_path(src))
409
+ props['stream'] = f'ExtResource("{res_id}")'
410
+ tscn.add_node(sound['id'], 'AudioStreamPlayer', parent='Sounds', properties=props)
411
+
412
+ # Particles
413
+ for particle in self._particles:
414
+ self._add_particle_to_tscn(tscn, particle, parent='.')
415
+
416
+ # Timers
417
+ for timer in self._timers:
418
+ props = {
419
+ 'wait_time': timer['interval'],
420
+ 'one_shot': timer['repeat'] == 1,
421
+ 'autostart': timer['auto_start'],
422
+ }
423
+ tscn.add_node(timer['id'], 'Timer', parent='.', properties=props)
424
+
425
+ # HUD
426
+ if self._huds:
427
+ self._add_hud_to_tscn(tscn)
428
+
429
+ # Instances
430
+ for inst in self._instances:
431
+ self._add_instance_to_tscn(tscn, inst, parent='.')
432
+
433
+ # Write .tscn
434
+ tscn_content = tscn.build()
435
+ (scenes_dir / f'{scene_name}.tscn').write_text(tscn_content, encoding='utf-8')
436
+
437
+ # Write scene script — map scenes get map_controller logic
438
+ if getattr(scene, 'scene_type', 'level') == 'map' and self._map_nodes:
439
+ script = self._build_map_scene_script(scene)
440
+ else:
441
+ script = self._build_multi_scene_script(scene)
442
+ (scripts_dir / f'scene_{scene_name}.gd').write_text(script, encoding='utf-8')
443
+
444
+ # Write HUD script if needed (per-scene)
445
+ if self._huds:
446
+ self._write_hud_script(scripts_dir)
447
+
448
+ def _build_multi_scene_script(self, scene: SceneNode) -> str:
449
+ """Generate a scene GDScript with SceneManager transition support."""
450
+ gd = GdScriptBuilder()
451
+ gd.extends('Node2D')
452
+ gd.blank()
453
+
454
+ # State variables
455
+ if self._state_vars:
456
+ gd.comment('State variables')
457
+ for sv in self._state_vars:
458
+ gd.var(sv['name'], sv['value'])
459
+ gd.blank()
460
+
461
+ # Identify player sprite
462
+ player_id = None
463
+ for sprite in self._sprites:
464
+ if sprite.get('tag') == 'player' or sprite.get('controls'):
465
+ player_id = sprite['id']
466
+ break
467
+
468
+ # Death sequence config from scene attrs
469
+ death_sequence = getattr(scene, 'death_sequence', None) or 'smw'
470
+ death_timer = float(getattr(scene, 'death_timer', 3.0) or 3.0)
471
+
472
+ # Build sound trigger map: event_name -> [sound_id, ...]
473
+ sound_triggers = {}
474
+ bgm_sounds = []
475
+ for sound in self._sounds:
476
+ trigger = sound.get('trigger', '')
477
+ if trigger == 'scene.start':
478
+ bgm_sounds.append(sound['id'])
479
+ elif trigger:
480
+ sound_triggers.setdefault(trigger, []).append(sound['id'])
481
+
482
+ # Build event action map and transition map
483
+ event_actions = {} # event_name -> [actions]
484
+ event_transitions = {} # event_name -> [transitions]
485
+ event_handlers = {} # event_name -> handler_name (custom)
486
+ for evt in self._events:
487
+ if evt.get('actions'):
488
+ event_actions[evt['name']] = evt['actions']
489
+ if evt.get('transitions'):
490
+ event_transitions[evt['name']] = evt['transitions']
491
+ if evt.get('handler'):
492
+ event_handlers[evt['name']] = evt['handler']
493
+
494
+ # _ready
495
+ gd.func('_ready')
496
+ gd.line(f'SceneManager.current_scene_name = "{scene.name}"')
497
+
498
+ # Register sprites with bridge
499
+ for sprite in self._sprites:
500
+ sid = sprite['id']
501
+ gd.line(f'QuantumBridge.register_sprite("{sid}", {_gd_node_ref(sid)})')
502
+ gd.blank()
503
+
504
+ # Set death texture on player
505
+ if player_id:
506
+ death_sprites = [s for s in self._sprites if s['id'] == '_preload_death']
507
+ if death_sprites:
508
+ gd.comment('Death texture')
509
+ gd.line(f'{_gd_node_ref(player_id)}.set_death_texture({_gd_node_ref("_preload_death")}.texture)')
510
+ gd.blank()
511
+
512
+ # Initialize HUD from persistent state
513
+ if self._huds:
514
+ gd.comment('Initialize HUD')
515
+ gd.line('$HUD.update_label("lives", SceneManager.get_state("lives", 5))')
516
+ gd.line('$HUD.update_label("coins", SceneManager.get_state("coins", 0))')
517
+ gd.line('$HUD.update_label("score", SceneManager.get_state("score", 0))')
518
+ gd.line('$HUD.update_label("yoshi_coins", SceneManager.get_state("yoshi_coins", 0))')
519
+ # Find time_left state var
520
+ time_val = 300
521
+ for sv in self._state_vars:
522
+ if sv['name'] == 'time_left':
523
+ time_val = sv['value']
524
+ break
525
+ gd.line(f'$HUD.set_time({time_val})')
526
+ gd.blank()
527
+
528
+ # Event listeners — register all handlers
529
+ all_events = set()
530
+ gd.comment('Event listeners')
531
+ for evt in self._events:
532
+ evt_name = evt['name']
533
+ all_events.add(evt_name)
534
+ func_name = f'_on_{_sanitize_name(evt_name)}'
535
+ gd.line(f'QuantumEventBus.listen("{evt_name}", Callable(self, "{func_name}"))')
536
+
537
+ # Register sound-only triggers (events not already registered)
538
+ for trigger_event in sound_triggers:
539
+ if trigger_event not in all_events and trigger_event != 'player.jump':
540
+ func_name = f'_on_{_sanitize_name(trigger_event)}'
541
+ gd.line(f'QuantumEventBus.listen("{trigger_event}", Callable(self, "{func_name}"))')
542
+ all_events.add(trigger_event)
543
+ gd.blank()
544
+
545
+ # Timer connections
546
+ for timer in self._timers:
547
+ gd.line(f'${timer["id"]}.timeout.connect(Callable(self, "{timer["action"]}"))')
548
+
549
+ # Tweens
550
+ if self._tweens:
551
+ gd.blank()
552
+ gd.comment('Tweens')
553
+ for tw in self._tweens:
554
+ if tw['auto_start']:
555
+ target = tw['target']
556
+ prop = _quantum_prop_to_godot(tw['property'])
557
+ to_val = tw['to_value']
558
+ dur = tw['duration']
559
+ ease = EASING_MAP.get(tw['easing'], 'Tween.EASE_IN_OUT')
560
+ trans = TRANS_MAP.get(tw['easing'], 'Tween.TRANS_LINEAR')
561
+ gd.line(f'var tween_{tw["id"]} = create_tween()')
562
+ gd.line(f'tween_{tw["id"]}.tween_property(${target}, "{prop}", {to_val}, {dur}).set_ease({ease}).set_trans({trans})')
563
+ if tw['loop']:
564
+ gd.line(f'tween_{tw["id"]}.set_loops()')
565
+
566
+ # Start BGM
567
+ if bgm_sounds:
568
+ gd.blank()
569
+ gd.comment('Start background music')
570
+ for bgm_id in bgm_sounds:
571
+ gd.line(f'_play_sound("{bgm_id}")')
572
+
573
+ gd.func_close()
574
+
575
+ # User functions
576
+ for func in self._functions:
577
+ gd.blank()
578
+ self._emit_function(gd, func)
579
+
580
+ # ---- Auto-generated event handler functions ----
581
+
582
+ for evt in self._events:
583
+ evt_name = evt['name']
584
+ func_name = f'_on_{_sanitize_name(evt_name)}'
585
+ handler_name = evt.get('handler', '')
586
+ actions = evt.get('actions', [])
587
+ transitions = evt.get('transitions', [])
588
+ trigger_sounds = sound_triggers.get(evt_name, [])
589
+
590
+ gd.blank()
591
+ gd.func(func_name, '_data = null')
592
+
593
+ # --- Special handler: enemy-collision ---
594
+ if handler_name == 'onEnemyCollision' or evt_name == 'enemy-collision':
595
+ gd.line('if isDead: return')
596
+ gd.line('var normal_y = _data.get("normal_y", 0) if _data else 0')
597
+ gd.line('var other = _data.get("other") if _data else null')
598
+ gd.if_block('normal_y < -0.5 and other and other.has_method("stomp")')
599
+ gd.line('other.stomp()')
600
+ if player_id:
601
+ gd.line(f'if {_gd_node_ref(player_id)}.has_method("stomp_bounce"):')
602
+ gd.line(f'\t{_gd_node_ref(player_id)}.stomp_bounce(-200)')
603
+ gd.line(f'else:')
604
+ gd.line(f'\t{_gd_node_ref(player_id)}.velocity.y = -200')
605
+ for sid in sound_triggers.get('enemy-stomped', []):
606
+ gd.line(f'_play_sound("{sid}")')
607
+ gd.else_block()
608
+ gd.line('_die()')
609
+ gd.block_close()
610
+
611
+ # --- Special handler: fell-in-pit ---
612
+ elif handler_name == 'onFellInPit' or evt_name == 'fell-in-pit':
613
+ gd.line('_die()')
614
+
615
+ # --- Special handler: block-hit ---
616
+ elif handler_name == 'onBlockHit' or evt_name == 'block-hit':
617
+ for sid in trigger_sounds:
618
+ gd.line(f'_play_sound("{sid}")')
619
+ if not trigger_sounds:
620
+ gd.line('pass')
621
+
622
+ # --- Generic event with actions/transitions ---
623
+ else:
624
+ # Play associated sounds
625
+ for sid in trigger_sounds:
626
+ gd.line(f'_play_sound("{sid}")')
627
+
628
+ # Execute inline actions
629
+ for action in actions:
630
+ atype = action.get('action_type', '')
631
+ if atype == 'score':
632
+ amount = action.get('amount', 0)
633
+ gd.line(f'SceneManager.set_state("score", SceneManager.get_state("score", 0) + {amount})')
634
+ if self._huds:
635
+ gd.line('$HUD.update_label("score", SceneManager.get_state("score", 0))')
636
+ elif atype == 'set':
637
+ target = action.get('target', '')
638
+ value = action.get('value', '')
639
+ # Handle {var+1} expressions
640
+ if value and '{' in str(value):
641
+ inner = str(value).strip('{}')
642
+ if '+' in inner:
643
+ parts = inner.split('+')
644
+ var_name = parts[0].strip()
645
+ increment = parts[1].strip()
646
+ gd.line(f'SceneManager.set_state("{target}", SceneManager.get_state("{var_name}", 0) + {increment})')
647
+ else:
648
+ gd.line(f'SceneManager.set_state("{target}", {inner})')
649
+ else:
650
+ gd.line(f'SceneManager.set_state("{target}", {value})')
651
+ if self._huds and target in ('coins', 'lives', 'score', 'yoshi_coins', 'time_left'):
652
+ gd.line(f'$HUD.update_label("{target}", SceneManager.get_state("{target}", 0))')
653
+
654
+ # Execute transitions
655
+ for tr in transitions:
656
+ data_str = '{}'
657
+ if tr.get('data'):
658
+ pairs = ', '.join(f'"{k}": "{v}"' for k, v in tr['data'].items())
659
+ data_str = '{' + pairs + '}'
660
+ # Stop timer and music before transition
661
+ if self._huds:
662
+ gd.line('$HUD.stop_timer()')
663
+ for bgm_id in bgm_sounds:
664
+ gd.line(f'_stop_sound("{bgm_id}")')
665
+ gd.line(f'SceneManager.transition_to("{tr["to"]}", "{tr["effect"]}", {tr["duration"]}, {data_str})')
666
+
667
+ if not trigger_sounds and not actions and not transitions:
668
+ gd.line('pass')
669
+
670
+ gd.func_close()
671
+
672
+ # Sound-only event handlers (for triggers not covered by events)
673
+ for trigger_event, sound_ids in sound_triggers.items():
674
+ if trigger_event not in {e['name'] for e in self._events} and trigger_event != 'player.jump':
675
+ func_name = f'_on_{_sanitize_name(trigger_event)}'
676
+ gd.blank()
677
+ gd.func(func_name, '_data = null')
678
+ for sid in sound_ids:
679
+ gd.line(f'_play_sound("{sid}")')
680
+ gd.func_close()
681
+
682
+ # _die() function
683
+ if player_id:
684
+ gd.blank()
685
+ gd.func('_die')
686
+ gd.line('if isDead: return')
687
+ gd.assign('isDead', '1')
688
+ if player_id:
689
+ gd.line(f'{_gd_node_ref(player_id)}.die()')
690
+ if self._huds:
691
+ gd.line('$HUD.stop_timer()')
692
+ # Play death sound
693
+ for sid in sound_triggers.get('mario-died', []):
694
+ gd.line(f'_play_sound("{sid}")')
695
+ # Stop BGM
696
+ for bgm_id in bgm_sounds:
697
+ gd.line(f'_stop_sound("{bgm_id}")')
698
+ # Wait and respawn
699
+ gd.line(f'await get_tree().create_timer({death_timer}).timeout')
700
+ gd.line('var lives = SceneManager.get_state("lives", 5) - 1')
701
+ gd.line('SceneManager.set_state("lives", lives)')
702
+ gd.if_block('lives <= 0')
703
+ gd.line('QuantumEventBus.emit_event("game-over", {})')
704
+ gd.else_block()
705
+ gd.line('get_tree().reload_current_scene()')
706
+ gd.block_close()
707
+ gd.func_close()
708
+
709
+ # _play_sound / _stop_sound helpers (use relative path within scene)
710
+ gd.blank()
711
+ gd.func('_play_sound', 'id: String')
712
+ gd.line('var player = get_node_or_null("Sounds/" + id)')
713
+ gd.line('if player: player.play()')
714
+ gd.func_close()
715
+ gd.blank()
716
+ gd.func('_stop_sound', 'id: String')
717
+ gd.line('var player = get_node_or_null("Sounds/" + id)')
718
+ gd.line('if player: player.stop()')
719
+ gd.func_close()
720
+
721
+ return gd.build()
722
+
723
+ def _build_map_scene_script(self, scene: SceneNode) -> str:
724
+ """Generate a map scene GDScript with path-based navigation."""
725
+ gd = GdScriptBuilder()
726
+ gd.extends('Node2D')
727
+ gd.blank()
728
+
729
+ # Build map data dictionary
730
+ gd.comment('Map data')
731
+ nodes_dict_parts = []
732
+ start_node = ''
733
+ for mn in self._map_nodes:
734
+ node_id = mn['id']
735
+ if not start_node:
736
+ start_node = node_id
737
+ locked = 'true' if mn.get('locked') else 'false'
738
+ scene_str = f'"{mn["scene"]}"' if mn.get('scene') else '""'
739
+ nodes_dict_parts.append(
740
+ f'"{node_id}": {{"x": {mn["x"]}, "y": {mn["y"]}, "scene": {scene_str}, "locked": {locked}}}'
741
+ )
742
+ nodes_str = ', '.join(nodes_dict_parts)
743
+ gd.line(f'var map_nodes: Dictionary = {{{nodes_str}}}')
744
+ gd.blank()
745
+
746
+ paths_parts = []
747
+ for mp in self._map_paths:
748
+ unlock_str = f'"{mp["unlock"]}"' if mp.get('unlock') else '""'
749
+ paths_parts.append(f'{{"from": "{mp["from"]}", "to": "{mp["to"]}", "unlock": {unlock_str}}}')
750
+ paths_str = ', '.join(paths_parts)
751
+ gd.line(f'var map_paths: Array = [{paths_str}]')
752
+ gd.blank()
753
+
754
+ gd.line(f'var current_node: String = "{start_node}"')
755
+ gd.line('var _mario_sprite: Sprite2D')
756
+ gd.line('var _moving: bool = false')
757
+ gd.blank()
758
+
759
+ # _ready
760
+ gd.func('_ready')
761
+ gd.line(f'SceneManager.current_scene_name = "{scene.name}"')
762
+ # Register sprites
763
+ for sprite in self._sprites:
764
+ sid = sprite['id']
765
+ gd.line(f'QuantumBridge.register_sprite("{sid}", {_gd_node_ref(sid)})')
766
+ gd.blank()
767
+ gd.line('_mario_sprite = $Mario if has_node("Mario") else null')
768
+ gd.line('if _mario_sprite and map_nodes.has(current_node):')
769
+ gd.indent()
770
+ gd.line('var node_info = map_nodes[current_node]')
771
+ gd.line('_mario_sprite.position = Vector2(node_info["x"], node_info["y"])')
772
+ gd.dedent()
773
+
774
+ # Event listeners
775
+ if self._events:
776
+ gd.blank()
777
+ for evt in self._events:
778
+ handler = evt['handler']
779
+ if handler:
780
+ gd.line(f'QuantumEventBus.listen("{evt["name"]}", Callable(self, "{handler}"))')
781
+ gd.func_close()
782
+ gd.blank()
783
+
784
+ # _input for map navigation
785
+ gd.func('_input', 'event: InputEvent')
786
+ gd.line('if _moving:')
787
+ gd.indent()
788
+ gd.line('return')
789
+ gd.dedent()
790
+ gd.line('if event.is_action_pressed("ui_accept"):')
791
+ gd.indent()
792
+ gd.line('_enter_node()')
793
+ gd.dedent()
794
+ gd.line('elif event.is_action_pressed("ui_right"):')
795
+ gd.indent()
796
+ gd.line('_try_move("right")')
797
+ gd.dedent()
798
+ gd.line('elif event.is_action_pressed("ui_left"):')
799
+ gd.indent()
800
+ gd.line('_try_move("left")')
801
+ gd.dedent()
802
+ gd.line('elif event.is_action_pressed("ui_up"):')
803
+ gd.indent()
804
+ gd.line('_try_move("up")')
805
+ gd.dedent()
806
+ gd.line('elif event.is_action_pressed("ui_down"):')
807
+ gd.indent()
808
+ gd.line('_try_move("down")')
809
+ gd.dedent()
810
+ gd.func_close()
811
+ gd.blank()
812
+
813
+ # _enter_node
814
+ gd.func('_enter_node')
815
+ gd.line('var node_info = map_nodes.get(current_node, {})')
816
+ gd.line('var scene_name = node_info.get("scene", "")')
817
+ gd.line('if scene_name != "" and not node_info.get("locked", false):')
818
+ gd.indent()
819
+ gd.line('SceneManager.transition_to(scene_name, "iris-in", 0.5)')
820
+ gd.dedent()
821
+ gd.func_close()
822
+ gd.blank()
823
+
824
+ # _try_move
825
+ gd.func('_try_move', 'direction: String')
826
+ gd.line('var neighbors = _get_neighbors(current_node)')
827
+ gd.line('var best_node = ""')
828
+ gd.line('var best_dist = INF')
829
+ gd.line('var current_pos = _get_node_pos(current_node)')
830
+ gd.line('for neighbor in neighbors:')
831
+ gd.indent()
832
+ gd.line('var neighbor_pos = _get_node_pos(neighbor)')
833
+ gd.line('var delta = neighbor_pos - current_pos')
834
+ gd.line('var valid = false')
835
+ gd.line('match direction:')
836
+ gd.indent()
837
+ gd.line('"right": valid = delta.x > 0 and abs(delta.x) >= abs(delta.y)')
838
+ gd.line('"left": valid = delta.x < 0 and abs(delta.x) >= abs(delta.y)')
839
+ gd.line('"up": valid = delta.y < 0 and abs(delta.y) >= abs(delta.x)')
840
+ gd.line('"down": valid = delta.y > 0 and abs(delta.y) >= abs(delta.x)')
841
+ gd.dedent()
842
+ gd.line('if valid:')
843
+ gd.indent()
844
+ gd.line('var dist = delta.length()')
845
+ gd.line('if dist < best_dist:')
846
+ gd.indent()
847
+ gd.line('best_dist = dist')
848
+ gd.line('best_node = neighbor')
849
+ gd.dedent()
850
+ gd.dedent()
851
+ gd.dedent()
852
+ gd.line('if best_node != "":')
853
+ gd.indent()
854
+ gd.line('_move_to(best_node)')
855
+ gd.dedent()
856
+ gd.func_close()
857
+ gd.blank()
858
+
859
+ # _move_to
860
+ gd.func('_move_to', 'target_node: String')
861
+ gd.line('_moving = true')
862
+ gd.line('var target_pos = _get_node_pos(target_node)')
863
+ gd.line('if _mario_sprite:')
864
+ gd.indent()
865
+ gd.line('var tween = create_tween()')
866
+ gd.line('tween.tween_property(_mario_sprite, "position", target_pos, 0.2)')
867
+ gd.line('await tween.finished')
868
+ gd.dedent()
869
+ gd.line('current_node = target_node')
870
+ gd.line('_moving = false')
871
+ gd.func_close()
872
+ gd.blank()
873
+
874
+ # _get_neighbors
875
+ gd.func('_get_neighbors', 'node_id: String')
876
+ gd.line('var neighbors = []')
877
+ gd.line('for path in map_paths:')
878
+ gd.indent()
879
+ gd.line('var unlocked = true')
880
+ gd.line('if path.has("unlock") and path["unlock"] != "":')
881
+ gd.indent()
882
+ gd.line('unlocked = SceneManager.get_state(path["unlock"], false)')
883
+ gd.dedent()
884
+ gd.line('if not unlocked:')
885
+ gd.indent()
886
+ gd.line('continue')
887
+ gd.dedent()
888
+ gd.line('if path["from"] == node_id:')
889
+ gd.indent()
890
+ gd.line('neighbors.append(path["to"])')
891
+ gd.dedent()
892
+ gd.line('elif path["to"] == node_id:')
893
+ gd.indent()
894
+ gd.line('neighbors.append(path["from"])')
895
+ gd.dedent()
896
+ gd.dedent()
897
+ gd.line('return neighbors')
898
+ gd.func_close()
899
+ gd.blank()
900
+
901
+ # _get_node_pos
902
+ gd.func('_get_node_pos', 'node_id: String')
903
+ gd.line('if map_nodes.has(node_id):')
904
+ gd.indent()
905
+ gd.line('var info = map_nodes[node_id]')
906
+ gd.line('return Vector2(info["x"], info["y"])')
907
+ gd.dedent()
908
+ gd.line('return Vector2.ZERO')
909
+ gd.func_close()
910
+
911
+ # User functions
912
+ for func in self._functions:
913
+ gd.blank()
914
+ self._emit_function(gd, func)
915
+
916
+ return gd.build()
917
+
918
+ # ------------------------------------------------------------------
919
+ # AST Walking
920
+ # ------------------------------------------------------------------
921
+
922
+ def _process_scene_children(self, children: List[QuantumNode]):
923
+ """Walk scene children and collect data for generation."""
924
+ for child in children:
925
+ self._process_node(child)
926
+
927
+ def _process_node(self, node: QuantumNode):
928
+ """Process a single AST node."""
929
+ if isinstance(node, SpriteNode):
930
+ self._process_sprite(node)
931
+ elif isinstance(node, PhysicsNode):
932
+ self._process_physics(node)
933
+ elif isinstance(node, CameraNode):
934
+ self._process_camera(node)
935
+ elif isinstance(node, InputNode):
936
+ self._process_input(node)
937
+ elif isinstance(node, SoundNode):
938
+ self._process_sound(node)
939
+ elif isinstance(node, ParticleNode):
940
+ self._process_particle(node)
941
+ elif isinstance(node, TimerNode):
942
+ self._process_timer(node)
943
+ elif isinstance(node, TweenNode):
944
+ self._process_tween(node)
945
+ elif isinstance(node, HudNode):
946
+ self._process_hud(node)
947
+ elif isinstance(node, TilemapNode):
948
+ self._process_tilemap(node)
949
+ elif isinstance(node, SpawnNode):
950
+ self._process_spawn(node)
951
+ elif isinstance(node, PrefabNode):
952
+ self._prefabs[node.name] = node
953
+ elif isinstance(node, EnemyNode):
954
+ self._enemies.append(node)
955
+ elif isinstance(node, InstanceNode):
956
+ self._process_instance(node)
957
+ elif isinstance(node, GroupNode):
958
+ self._process_group(node)
959
+ elif isinstance(node, SetNode):
960
+ self._process_set(node)
961
+ elif isinstance(node, FunctionNode):
962
+ self._functions.append(node)
963
+ elif isinstance(node, EventNode):
964
+ self._process_event(node)
965
+ elif isinstance(node, BehaviorNode):
966
+ self._behaviors[node.name] = node
967
+ elif isinstance(node, MapNodeDef):
968
+ self._map_nodes.append({
969
+ 'id': node.node_id,
970
+ 'x': node.x,
971
+ 'y': node.y,
972
+ 'scene': node.scene,
973
+ 'locked': node.locked,
974
+ 'icon': node.icon,
975
+ })
976
+ if node.icon:
977
+ self._assets.add(node.icon)
978
+ elif isinstance(node, MapPathNode):
979
+ self._map_paths.append({
980
+ 'from': node.from_node,
981
+ 'to': node.to_node,
982
+ 'unlock': node.unlock,
983
+ 'direction': node.direction,
984
+ })
985
+
986
+ def _process_sprite(self, node: SpriteNode):
987
+ """Collect sprite data."""
988
+ sprite_data = {
989
+ 'id': node.sprite_id,
990
+ 'src': node.src,
991
+ 'x': node.x,
992
+ 'y': node.y,
993
+ 'width': node.width,
994
+ 'height': node.height,
995
+ 'anchor_x': node.anchor_x,
996
+ 'anchor_y': node.anchor_y,
997
+ 'rotation': node.rotation,
998
+ 'scale_x': node.scale_x,
999
+ 'scale_y': node.scale_y,
1000
+ 'alpha': node.alpha,
1001
+ 'visible': node.visible,
1002
+ 'color': node.color,
1003
+ 'tag': node.tag,
1004
+ 'layer': node.layer,
1005
+ 'frame_width': node.frame_width,
1006
+ 'frame_height': node.frame_height,
1007
+ 'body': node.body,
1008
+ 'shape': node.shape,
1009
+ 'bounce': node.bounce,
1010
+ 'friction': node.friction,
1011
+ 'mass': node.mass,
1012
+ 'sensor': node.sensor,
1013
+ 'controls': node.controls,
1014
+ 'speed': node.speed,
1015
+ 'jump_force': node.jump_force,
1016
+ 'gravity_up': node.gravity_up,
1017
+ 'gravity_down': node.gravity_down,
1018
+ 'jump_hold_boost': node.jump_hold_boost,
1019
+ 'coyote_frames': node.coyote_frames,
1020
+ 'max_fall_speed': node.max_fall_speed,
1021
+ 'collision_layer': node.collision_layer,
1022
+ 'collision_mask': node.collision_mask,
1023
+ 'floor_max_angle': node.floor_max_angle,
1024
+ 'spin_jump': getattr(node, 'spin_jump', False),
1025
+ 'animations': [],
1026
+ 'colliders': [],
1027
+ 'on_collisions': [],
1028
+ 'behaviors': [],
1029
+ 'children': [],
1030
+ }
1031
+
1032
+ if node.src:
1033
+ self._assets.add(node.src)
1034
+
1035
+ # Process sprite children
1036
+ for child in node.children:
1037
+ if isinstance(child, AnimationNode):
1038
+ sprite_data['animations'].append({
1039
+ 'name': child.name,
1040
+ 'frames': child.frames,
1041
+ 'speed': child.speed,
1042
+ 'loop': child.loop,
1043
+ 'auto_play': child.auto_play,
1044
+ })
1045
+ elif isinstance(child, ColliderNode):
1046
+ sprite_data['colliders'].append({
1047
+ 'shape': child.shape,
1048
+ 'width': child.width,
1049
+ 'height': child.height,
1050
+ 'radius': child.radius,
1051
+ 'offset_x': child.offset_x,
1052
+ 'offset_y': child.offset_y,
1053
+ })
1054
+ elif isinstance(child, OnCollisionNode):
1055
+ sprite_data['on_collisions'].append({
1056
+ 'with_tag': child.with_tag,
1057
+ 'with_id': child.with_id,
1058
+ 'action': child.action,
1059
+ })
1060
+ elif isinstance(child, UseNode):
1061
+ sprite_data['behaviors'].append({
1062
+ 'behavior': child.behavior,
1063
+ 'overrides': child.overrides,
1064
+ })
1065
+ elif isinstance(child, ClickableNode):
1066
+ sprite_data['children'].append({'type': 'clickable', 'action': child.action})
1067
+ else:
1068
+ sprite_data['children'].append(child)
1069
+
1070
+ self._sprites.append(sprite_data)
1071
+
1072
+ def _process_physics(self, node: PhysicsNode):
1073
+ self._physics = {
1074
+ 'gravity_x': node.gravity_x,
1075
+ 'gravity_y': node.gravity_y,
1076
+ 'bounds': node.bounds,
1077
+ 'debug': node.debug,
1078
+ 'collision_layers': node.collision_layers,
1079
+ }
1080
+
1081
+ def _process_camera(self, node: CameraNode):
1082
+ self._camera = {
1083
+ 'follow': node.follow,
1084
+ 'lerp': node.lerp,
1085
+ 'bounds': node.bounds,
1086
+ 'zoom': node.zoom,
1087
+ 'offset_x': node.offset_x,
1088
+ 'offset_y': node.offset_y,
1089
+ }
1090
+
1091
+ def _process_input(self, node: InputNode):
1092
+ self._custom_inputs.append({
1093
+ 'key': node.key,
1094
+ 'action': node.action,
1095
+ 'type': node.input_type,
1096
+ })
1097
+
1098
+ def _process_sound(self, node: SoundNode):
1099
+ self._sounds.append({
1100
+ 'id': node.sound_id,
1101
+ 'src': node.src,
1102
+ 'volume': node.volume,
1103
+ 'loop': node.loop,
1104
+ 'trigger': node.trigger,
1105
+ 'channel': node.channel,
1106
+ })
1107
+ if node.src:
1108
+ self._assets.add(node.src)
1109
+
1110
+ def _process_particle(self, node: ParticleNode):
1111
+ self._particles.append({
1112
+ 'id': node.particle_id,
1113
+ 'src': node.src,
1114
+ 'follow': node.follow,
1115
+ 'trigger': node.trigger,
1116
+ 'count': node.count,
1117
+ 'emit_rate': node.emit_rate,
1118
+ 'lifetime': node.lifetime,
1119
+ 'speed_min': node.speed_min,
1120
+ 'speed_max': node.speed_max,
1121
+ 'angle_min': node.angle_min,
1122
+ 'angle_max': node.angle_max,
1123
+ 'alpha_start': node.alpha_start,
1124
+ 'alpha_end': node.alpha_end,
1125
+ })
1126
+
1127
+ def _process_timer(self, node: TimerNode):
1128
+ self._timers.append({
1129
+ 'id': node.timer_id,
1130
+ 'interval': node.interval,
1131
+ 'repeat': node.repeat,
1132
+ 'auto_start': node.auto_start,
1133
+ 'action': node.action,
1134
+ })
1135
+
1136
+ def _process_tween(self, node: TweenNode):
1137
+ self._tweens.append({
1138
+ 'id': node.tween_id,
1139
+ 'target': node.target,
1140
+ 'property': node.property,
1141
+ 'to_value': node.to_value,
1142
+ 'duration': node.duration,
1143
+ 'easing': node.easing,
1144
+ 'loop': node.loop,
1145
+ 'yoyo': node.yoyo,
1146
+ 'delay': node.delay,
1147
+ 'auto_start': node.auto_start,
1148
+ })
1149
+
1150
+ def _process_hud(self, node: HudNode):
1151
+ hud_data = {
1152
+ 'position': node.position,
1153
+ 'background': node.background,
1154
+ 'background_height': node.background_height,
1155
+ 'sprite_prefix': node.sprite_prefix,
1156
+ 'children': [],
1157
+ 'tiles': [],
1158
+ 'counters': [],
1159
+ 'collections': [],
1160
+ 'slots': [],
1161
+ 'behaviors': [],
1162
+ }
1163
+ for child in node.children:
1164
+ if isinstance(child, HudTileNode):
1165
+ sprite_path = self._resolve_hud_sprite(child.sprite, node.sprite_prefix)
1166
+ self._assets.add(sprite_path)
1167
+ hud_data['tiles'].append({
1168
+ 'sprite': sprite_path,
1169
+ 'x': child.x,
1170
+ 'y': child.y,
1171
+ 'width': child.width,
1172
+ 'height': child.height,
1173
+ })
1174
+ elif isinstance(child, HudCounterNode):
1175
+ strip_path = self._resolve_hud_sprite(child.strip, node.sprite_prefix)
1176
+ self._assets.add(strip_path)
1177
+ counter_data = {
1178
+ 'strip': strip_path,
1179
+ 'x': child.x,
1180
+ 'y': child.y,
1181
+ 'digits': child.digits,
1182
+ 'bind': self._strip_bind(child.bind),
1183
+ 'digit_width': child.digit_width,
1184
+ 'digit_height': child.digit_height,
1185
+ 'countdown': child.countdown,
1186
+ 'hurry_at': child.hurry_at,
1187
+ 'flash': child.flash,
1188
+ 'align': child.align,
1189
+ 'right_edge': child.right_edge,
1190
+ 'tint': child.tint,
1191
+ 'intelligent': child.intelligent,
1192
+ 'extra_life_at': child.extra_life_at,
1193
+ }
1194
+ if child.icon:
1195
+ icon_path = self._resolve_hud_sprite(child.icon, node.sprite_prefix)
1196
+ self._assets.add(icon_path)
1197
+ counter_data['icon'] = icon_path
1198
+ if child.symbol:
1199
+ symbol_path = self._resolve_hud_sprite(child.symbol, node.sprite_prefix)
1200
+ self._assets.add(symbol_path)
1201
+ counter_data['symbol'] = symbol_path
1202
+ hud_data['counters'].append(counter_data)
1203
+ elif isinstance(child, HudCollectionNode):
1204
+ sprite_path = self._resolve_hud_sprite(child.sprite, node.sprite_prefix)
1205
+ self._assets.add(sprite_path)
1206
+ hud_data['collections'].append({
1207
+ 'sprite': sprite_path,
1208
+ 'x': child.x,
1209
+ 'y': child.y,
1210
+ 'spacing': child.spacing,
1211
+ 'max': child.max,
1212
+ 'bind': self._strip_bind(child.bind),
1213
+ })
1214
+ elif isinstance(child, HudSlotNode):
1215
+ slot_data = {
1216
+ 'x': child.x,
1217
+ 'y': child.y,
1218
+ 'bind': self._strip_bind(child.bind),
1219
+ 'options': [],
1220
+ }
1221
+ for opt in child.options:
1222
+ opt_sprite = self._resolve_hud_sprite(opt.sprite, node.sprite_prefix)
1223
+ self._assets.add(opt_sprite)
1224
+ slot_data['options'].append({
1225
+ 'value': opt.value,
1226
+ 'sprite': opt_sprite,
1227
+ })
1228
+ hud_data['slots'].append(slot_data)
1229
+ elif isinstance(child, HudBehaviorNode):
1230
+ behavior_data = {
1231
+ 'target': child.target,
1232
+ 'event': child.event,
1233
+ 'value': child.value,
1234
+ 'actions': [],
1235
+ }
1236
+ for action in child.actions:
1237
+ behavior_data['actions'].append({
1238
+ 'action_type': action.action_type,
1239
+ 'event': action.event,
1240
+ 'rate': action.rate,
1241
+ })
1242
+ hud_data['behaviors'].append(behavior_data)
1243
+ elif isinstance(child, HTMLNode):
1244
+ hud_data['children'].append({
1245
+ 'type': 'html',
1246
+ 'tag': child.tag,
1247
+ 'attrs': child.attributes,
1248
+ 'children': child.children,
1249
+ })
1250
+ elif isinstance(child, TextNode):
1251
+ hud_data['children'].append({
1252
+ 'type': 'text',
1253
+ 'content': child.content,
1254
+ })
1255
+ self._huds.append(hud_data)
1256
+
1257
+ def _has_tile_hud(self) -> bool:
1258
+ """Check if any HUD uses tile-based mode."""
1259
+ for hud in self._huds:
1260
+ if hud.get('tiles') or hud.get('counters') or hud.get('collections') or hud.get('slots'):
1261
+ return True
1262
+ return False
1263
+
1264
+ def _resolve_hud_sprite(self, sprite: str, prefix: Optional[str]) -> str:
1265
+ """Resolve sprite name with prefix. Paths with '/' are used literally."""
1266
+ if not sprite:
1267
+ return sprite
1268
+ if '/' in sprite:
1269
+ return sprite
1270
+ if prefix:
1271
+ return prefix + sprite
1272
+ return sprite
1273
+
1274
+ def _strip_bind(self, bind: str) -> str:
1275
+ """Strip curly braces from bind expression: '{coins}' -> 'coins'."""
1276
+ if bind.startswith('{') and bind.endswith('}'):
1277
+ return bind[1:-1]
1278
+ return bind
1279
+
1280
+ def _sprite_var_name(self, path: str) -> str:
1281
+ """Convert sprite path to a GDScript variable name."""
1282
+ name = path.replace('/', '_').replace('.', '_').replace('-', '_')
1283
+ if name.startswith('_'):
1284
+ name = 'tex' + name
1285
+ return name
1286
+
1287
+ def _parse_rgba(self, rgba_str: str) -> str:
1288
+ """Parse 'rgba(r,g,b,a)' to Godot Color(r,g,b,a) format."""
1289
+ if not rgba_str:
1290
+ return 'Color(0, 0, 0, 0.5)'
1291
+ s = rgba_str.strip()
1292
+ if s.startswith('rgba(') and s.endswith(')'):
1293
+ parts = s[5:-1].split(',')
1294
+ if len(parts) == 4:
1295
+ r = float(parts[0].strip()) / 255.0
1296
+ g = float(parts[1].strip()) / 255.0
1297
+ b = float(parts[2].strip()) / 255.0
1298
+ a = float(parts[3].strip())
1299
+ return f'Color({r:.3f}, {g:.3f}, {b:.3f}, {a})'
1300
+ if s.startswith('rgb(') and s.endswith(')'):
1301
+ parts = s[4:-1].split(',')
1302
+ if len(parts) == 3:
1303
+ r = float(parts[0].strip()) / 255.0
1304
+ g = float(parts[1].strip()) / 255.0
1305
+ b = float(parts[2].strip()) / 255.0
1306
+ return f'Color({r:.3f}, {g:.3f}, {b:.3f}, 1.0)'
1307
+ return 'Color(0, 0, 0, 0.5)'
1308
+
1309
+ def _flash_color(self, color_name: Optional[str]) -> str:
1310
+ """Convert flash color name to Godot Color."""
1311
+ colors = {
1312
+ 'red': 'Color(1, 0.2, 0.2, 1)',
1313
+ 'yellow': 'Color(1, 1, 0, 1)',
1314
+ 'white': 'Color(1, 1, 1, 1)',
1315
+ 'blue': 'Color(0.2, 0.4, 1, 1)',
1316
+ }
1317
+ return colors.get(color_name, 'Color(1, 0.2, 0.2, 1)')
1318
+
1319
+ def _process_tilemap(self, node: TilemapNode):
1320
+ tilemap_data = {
1321
+ 'id': node.tilemap_id,
1322
+ 'src': node.src,
1323
+ 'data_src': node.data_src,
1324
+ 'tile_width': node.tile_width,
1325
+ 'tile_height': node.tile_height,
1326
+ 'layers': [],
1327
+ 'tile_animations': [],
1328
+ }
1329
+ if node.src:
1330
+ self._assets.add(node.src)
1331
+ if node.data_src:
1332
+ self._assets.add(node.data_src)
1333
+
1334
+ for layer in node.layers:
1335
+ tilemap_data['layers'].append({
1336
+ 'name': layer.name,
1337
+ 'data': layer.data,
1338
+ 'collision': layer.collision,
1339
+ })
1340
+
1341
+ for anim in node.tile_animations:
1342
+ tilemap_data['tile_animations'].append({
1343
+ 'tile_id': anim.tile_id,
1344
+ 'frames': anim.frames,
1345
+ 'speed': anim.speed,
1346
+ })
1347
+
1348
+ self._tilemaps.append(tilemap_data)
1349
+
1350
+ def _process_spawn(self, node: SpawnNode):
1351
+ self._spawners.append({
1352
+ 'id': node.spawn_id,
1353
+ 'prefab': node.prefab,
1354
+ 'count': node.count,
1355
+ 'interval': node.interval,
1356
+ 'x': node.x,
1357
+ 'y': node.y,
1358
+ 'pool_size': node.pool_size,
1359
+ })
1360
+
1361
+ def _process_instance(self, node: InstanceNode):
1362
+ self._instances.append({
1363
+ 'prefab': node.prefab,
1364
+ 'id': node.instance_id,
1365
+ 'x': node.x,
1366
+ 'y': node.y,
1367
+ 'overrides': node.overrides,
1368
+ })
1369
+
1370
+ def _process_group(self, node: GroupNode):
1371
+ group_data = {
1372
+ 'name': node.name,
1373
+ 'tag': node.tag,
1374
+ 'children': [],
1375
+ }
1376
+ for child in node.children:
1377
+ if isinstance(child, SpriteNode):
1378
+ self._process_sprite(child)
1379
+ group_data['children'].append(child.sprite_id)
1380
+ elif isinstance(child, InstanceNode):
1381
+ self._process_instance(child)
1382
+ group_data['children'].append(child.instance_id or child.prefab)
1383
+ else:
1384
+ self._process_node(child)
1385
+ self._groups.append(group_data)
1386
+
1387
+ def _process_set(self, node: SetNode):
1388
+ val = node.value
1389
+ val_type = 'String'
1390
+ if val is None or val == '':
1391
+ val_type = 'Variant'
1392
+ val = 'null'
1393
+ elif isinstance(val, bool):
1394
+ val_type = 'bool'
1395
+ val = gd_bool(val)
1396
+ elif isinstance(val, int):
1397
+ val_type = 'int'
1398
+ val = str(val)
1399
+ elif isinstance(val, float):
1400
+ val_type = 'float'
1401
+ val = str(val)
1402
+ elif isinstance(val, str):
1403
+ # Try to detect numbers
1404
+ try:
1405
+ int(val)
1406
+ val_type = 'int'
1407
+ except ValueError:
1408
+ try:
1409
+ float(val)
1410
+ val_type = 'float'
1411
+ except ValueError:
1412
+ if val.lower() in ('true', 'false'):
1413
+ val_type = 'bool'
1414
+ val = val.lower()
1415
+ else:
1416
+ val_type = 'String'
1417
+ val = gd_string(val)
1418
+
1419
+ self._state_vars.append({
1420
+ 'name': node.name,
1421
+ 'value': val,
1422
+ 'type': val_type,
1423
+ })
1424
+
1425
+ def _process_event(self, node: EventNode):
1426
+ # Extract scene transitions and event actions from inline children
1427
+ transitions = []
1428
+ actions = []
1429
+ for child in getattr(node, 'children', []):
1430
+ if isinstance(child, SceneTransitionNode):
1431
+ transitions.append({
1432
+ 'to': child.to,
1433
+ 'effect': child.effect,
1434
+ 'duration': child.duration,
1435
+ 'data': child.data,
1436
+ })
1437
+ elif isinstance(child, EventActionNode):
1438
+ actions.append({
1439
+ 'action_type': child.action_type,
1440
+ 'target': child.target,
1441
+ 'value': child.value,
1442
+ 'amount': child.amount,
1443
+ })
1444
+ self._events.append({
1445
+ 'name': node.name,
1446
+ 'handler': node.handler,
1447
+ 'filter_tag': node.filter_tag,
1448
+ 'filter_id': node.filter_id,
1449
+ 'scope': node.scope,
1450
+ 'transitions': transitions,
1451
+ 'actions': actions,
1452
+ })
1453
+
1454
+ def _resolve_collision_value(self, raw_value) -> int:
1455
+ """Resolve a collision layer/mask value.
1456
+
1457
+ Supports:
1458
+ - int: direct bitmask value
1459
+ - str: single named layer or comma-separated names
1460
+ Returns an int bitmask.
1461
+ """
1462
+ if raw_value is None:
1463
+ return 1
1464
+ if isinstance(raw_value, int):
1465
+ return raw_value
1466
+ if isinstance(raw_value, str):
1467
+ collision_layers = {}
1468
+ if self._physics and self._physics.get('collision_layers'):
1469
+ collision_layers = self._physics['collision_layers']
1470
+ # Could be comma-separated names like "world,enemies"
1471
+ result = 0
1472
+ for part in raw_value.split(','):
1473
+ name = part.strip()
1474
+ if name in collision_layers:
1475
+ result |= collision_layers[name]
1476
+ else:
1477
+ # Try parsing as int
1478
+ try:
1479
+ result |= int(name)
1480
+ except ValueError:
1481
+ pass # Unknown layer name, skip
1482
+ return result if result > 0 else 1
1483
+ return 1
1484
+
1485
+ # ------------------------------------------------------------------
1486
+ # File Generation
1487
+ # ------------------------------------------------------------------
1488
+
1489
+ def _write_project_godot(self, out: Path, scene: SceneNode, project_name: str):
1490
+ """Generate and write project.godot."""
1491
+ gravity_y = SMW_GRAVITY
1492
+ if self._physics:
1493
+ # Use SNES gravity as default, ignore Quantum abstract gravity
1494
+ pass
1495
+
1496
+ # Build input map
1497
+ input_map = self._build_input_map()
1498
+
1499
+ # Build collision layer names
1500
+ collision_layer_names = {}
1501
+ if self._physics and self._physics.get('collision_layers'):
1502
+ collision_layer_names = self._physics['collision_layers']
1503
+
1504
+ config = {
1505
+ 'name': project_name,
1506
+ 'main_scene': 'main.tscn',
1507
+ 'viewport_width': scene.viewport_width or SNES_WIDTH,
1508
+ 'viewport_height': scene.viewport_height or SNES_HEIGHT,
1509
+ 'gravity': gravity_y,
1510
+ 'input_map': input_map,
1511
+ 'autoloads': {
1512
+ 'QuantumBridge': 'scripts/quantum_bridge.gd',
1513
+ 'QuantumEventBus': 'scripts/quantum_event_bus.gd',
1514
+ },
1515
+ 'collision_layer_names': collision_layer_names,
1516
+ }
1517
+ content = build_project_godot(config)
1518
+ (out / 'project.godot').write_text(content, encoding='utf-8')
1519
+
1520
+ def _build_input_map(self) -> Dict[str, List[str]]:
1521
+ """Build input action map from sprites with controls + custom inputs."""
1522
+ input_map = {}
1523
+
1524
+ # Check if any sprite has controls
1525
+ has_controls = any(s.get('controls') for s in self._sprites)
1526
+ if has_controls:
1527
+ input_map['move_left'] = ['ArrowLeft', 'a']
1528
+ input_map['move_right'] = ['ArrowRight', 'd']
1529
+ input_map['jump'] = ['Space', 'ArrowUp', 'w']
1530
+ input_map['move_down'] = ['ArrowDown', 's']
1531
+
1532
+ # Custom inputs
1533
+ for inp in self._custom_inputs:
1534
+ action = inp['action']
1535
+ key = inp['key']
1536
+ if action not in input_map:
1537
+ input_map[action] = []
1538
+ input_map[action].append(key)
1539
+
1540
+ return input_map
1541
+
1542
+ def _write_autoload_scripts(self, scripts_dir: Path):
1543
+ """Write autoload scripts (bridge, event bus)."""
1544
+ (scripts_dir / 'quantum_bridge.gd').write_text(
1545
+ QUANTUM_BRIDGE_GD, encoding='utf-8'
1546
+ )
1547
+ (scripts_dir / 'quantum_event_bus.gd').write_text(
1548
+ QUANTUM_EVENT_BUS_GD, encoding='utf-8'
1549
+ )
1550
+
1551
+ def _write_main_scene(self, out: Path, scripts_dir: Path, scene: SceneNode):
1552
+ """Generate the main .tscn scene and its script."""
1553
+ tscn = TscnBuilder()
1554
+ script_path = 'scripts/scene_main.gd'
1555
+
1556
+ # Root node: Node2D with script
1557
+ tscn.add_node_with_script('Main', 'Node2D', script_path=script_path)
1558
+
1559
+ # Background color (ColorRect) — sized to world, not viewport
1560
+ bg = scene.background or '#000000'
1561
+ tscn.add_node('Background', 'ColorRect', parent='.', properties={
1562
+ 'offset_right': float(scene.width),
1563
+ 'offset_bottom': float(scene.height),
1564
+ 'color': gd_color(bg),
1565
+ })
1566
+
1567
+ # Sprites
1568
+ node_index = 0
1569
+ for sprite in self._sprites:
1570
+ self._add_sprite_to_tscn(tscn, sprite, parent='.')
1571
+ node_index += 1
1572
+
1573
+ # Tilemaps
1574
+ for tilemap in self._tilemaps:
1575
+ self._add_tilemap_to_tscn(tscn, tilemap, parent='.')
1576
+
1577
+ # Camera
1578
+ if self._camera:
1579
+ self._add_camera_to_tscn(tscn)
1580
+
1581
+ # Sounds container
1582
+ if self._sounds:
1583
+ tscn.add_node('Sounds', 'Node', parent='.')
1584
+ for sound in self._sounds:
1585
+ props = {}
1586
+ if sound['volume'] != 1.0:
1587
+ import math
1588
+ props['volume_db'] = 20 * math.log10(max(sound['volume'], 0.001))
1589
+ if sound['loop']:
1590
+ pass # Loop is set on the AudioStream, not the player
1591
+ src = sound['src']
1592
+ if src:
1593
+ res_id = tscn.add_ext_resource('AudioStream', _asset_path(src))
1594
+ props['stream'] = f'ExtResource("{res_id}")'
1595
+ tscn.add_node(sound['id'], 'AudioStreamPlayer', parent='Sounds', properties=props)
1596
+
1597
+ # Particles
1598
+ for particle in self._particles:
1599
+ self._add_particle_to_tscn(tscn, particle, parent='.')
1600
+
1601
+ # Timers
1602
+ for timer in self._timers:
1603
+ props = {
1604
+ 'wait_time': timer['interval'],
1605
+ 'one_shot': timer['repeat'] == 1,
1606
+ 'autostart': timer['auto_start'],
1607
+ }
1608
+ tscn.add_node(timer['id'], 'Timer', parent='.', properties=props)
1609
+
1610
+ # HUD
1611
+ if self._huds:
1612
+ self._add_hud_to_tscn(tscn)
1613
+
1614
+ # Instances (of prefabs)
1615
+ for inst in self._instances:
1616
+ self._add_instance_to_tscn(tscn, inst, parent='.')
1617
+
1618
+ # Write .tscn
1619
+ tscn_content = tscn.build()
1620
+ (out / 'main.tscn').write_text(tscn_content, encoding='utf-8')
1621
+
1622
+ # Write scene script
1623
+ script = self._build_scene_script(scene)
1624
+ (scripts_dir / 'scene_main.gd').write_text(script, encoding='utf-8')
1625
+
1626
+ def _add_sprite_to_tscn(self, tscn: TscnBuilder, sprite: Dict, parent: str):
1627
+ """Add a sprite node to the TSCN."""
1628
+ sid = sprite['id'] or 'Sprite'
1629
+ body = sprite.get('body')
1630
+ sensor = sprite.get('sensor', False)
1631
+ controls = sprite.get('controls')
1632
+ has_animations = bool(sprite.get('animations'))
1633
+ tag = sprite.get('tag')
1634
+
1635
+ x = float(sprite['x'])
1636
+ y = float(sprite['y'])
1637
+
1638
+ # Base metadata — tag for collision dispatch
1639
+ meta = {}
1640
+ if tag:
1641
+ meta['metadata/tag'] = tag # TscnBuilder auto-quotes strings
1642
+
1643
+ if sensor:
1644
+ # Area2D for sensors
1645
+ node_type = 'Area2D'
1646
+ props = {'position': gd_vector2(x, y), **meta}
1647
+ tscn.add_node(sid, node_type, parent=parent, properties=props)
1648
+ self._add_collision_shape(tscn, sprite, parent=_node_path(parent, sid))
1649
+ self._add_sprite_visual(tscn, sprite, parent=_node_path(parent, sid))
1650
+
1651
+ elif body == 'dynamic' and controls:
1652
+ # CharacterBody2D with player controller
1653
+ node_type = 'CharacterBody2D'
1654
+ props = {'position': gd_vector2(x, y), **meta}
1655
+ tscn.add_node_with_script(
1656
+ sid, node_type, script_path='scripts/player_controller.gd',
1657
+ parent=parent, properties=props
1658
+ )
1659
+ self._add_collision_shape(tscn, sprite, parent=_node_path(parent, sid))
1660
+ self._add_sprite_visual(tscn, sprite, parent=_node_path(parent, sid),
1661
+ has_animations=has_animations)
1662
+
1663
+ elif body == 'dynamic':
1664
+ # CharacterBody2D without controls
1665
+ node_type = 'CharacterBody2D'
1666
+ props = {'position': gd_vector2(x, y), **meta}
1667
+ # Check if it has patrol/AI behavior
1668
+ has_patrol = False
1669
+ for beh in sprite.get('behaviors', []):
1670
+ bname = beh['behavior']
1671
+ if bname in self._behaviors:
1672
+ # Check for patrol-like behavior
1673
+ has_patrol = True
1674
+ if has_patrol or sprite.get('tag') in ('enemy',):
1675
+ tscn.add_node_with_script(
1676
+ sid, node_type, script_path=f'scripts/patrol_ai.gd',
1677
+ parent=parent, properties=props
1678
+ )
1679
+ else:
1680
+ tscn.add_node(sid, node_type, parent=parent, properties=props)
1681
+ self._add_collision_shape(tscn, sprite, parent=_node_path(parent, sid))
1682
+ self._add_sprite_visual(tscn, sprite, parent=_node_path(parent, sid),
1683
+ has_animations=has_animations)
1684
+
1685
+ elif body == 'static':
1686
+ # StaticBody2D
1687
+ node_type = 'StaticBody2D'
1688
+ props = {'position': gd_vector2(x, y), **meta}
1689
+ tscn.add_node(sid, node_type, parent=parent, properties=props)
1690
+ self._add_collision_shape(tscn, sprite, parent=_node_path(parent, sid))
1691
+ self._add_sprite_visual(tscn, sprite, parent=_node_path(parent, sid))
1692
+
1693
+ else:
1694
+ # Pure visual: Sprite2D or AnimatedSprite2D
1695
+ if has_animations:
1696
+ props = {'position': gd_vector2(x, y)}
1697
+ tscn.add_node(sid, 'AnimatedSprite2D', parent=parent, properties=props)
1698
+ else:
1699
+ props = {'position': gd_vector2(x, y)}
1700
+ if sprite['src']:
1701
+ res_id = tscn.add_ext_resource('Texture2D', _asset_path(sprite['src']))
1702
+ props['texture'] = f'ExtResource("{res_id}")'
1703
+ if sprite['alpha'] != 1.0:
1704
+ props['modulate'] = f'Color(1, 1, 1, {sprite["alpha"]})'
1705
+ if not sprite['visible']:
1706
+ props['visible'] = False
1707
+ if sprite['scale_x'] != 1.0 or sprite['scale_y'] != 1.0:
1708
+ props['scale'] = gd_vector2(sprite['scale_x'], sprite['scale_y'])
1709
+ if sprite['rotation'] != 0:
1710
+ import math
1711
+ props['rotation'] = sprite['rotation'] * math.pi / 180
1712
+ tscn.add_node(sid, 'Sprite2D', parent=parent, properties=props)
1713
+
1714
+ # Add collision handlers as Area2D children if needed
1715
+ if sprite.get('on_collisions') and not sensor:
1716
+ area_parent = _node_path(parent, sid)
1717
+ tscn.add_node(f'{sid}_area', 'Area2D', parent=area_parent)
1718
+ # Add collision shape to the area (slightly larger for generous detection)
1719
+ area_path = _node_path(area_parent, f'{sid}_area')
1720
+ w = sprite.get('width') or sprite.get('frame_width') or 32
1721
+ h = sprite.get('height') or sprite.get('frame_height') or 32
1722
+ shape_id = tscn.add_sub_resource('RectangleShape2D', {
1723
+ 'size': gd_vector2(w, h),
1724
+ })
1725
+ tscn.add_node('CollisionShape2D', 'CollisionShape2D', parent=area_path, properties={
1726
+ 'shape': shape_id,
1727
+ })
1728
+
1729
+ def _add_collision_shape(self, tscn: TscnBuilder, sprite: Dict, parent: str):
1730
+ """Add a CollisionShape2D to a physics body."""
1731
+ colliders = sprite.get('colliders', [])
1732
+ if colliders:
1733
+ for i, col in enumerate(colliders):
1734
+ name = f'CollisionShape2D{"" if i == 0 else str(i)}'
1735
+ shape = col['shape']
1736
+ if shape == 'circle':
1737
+ radius = col.get('radius') or (sprite.get('width') or 32) / 2
1738
+ shape_id = tscn.add_sub_resource('CircleShape2D', {
1739
+ 'radius': float(radius),
1740
+ })
1741
+ else:
1742
+ w = col.get('width') or sprite.get('width') or 32
1743
+ h = col.get('height') or sprite.get('height') or 32
1744
+ shape_id = tscn.add_sub_resource('RectangleShape2D', {
1745
+ 'size': gd_vector2(w, h),
1746
+ })
1747
+ props = {'shape': shape_id}
1748
+ if col.get('offset_x') or col.get('offset_y'):
1749
+ props['position'] = gd_vector2(
1750
+ col.get('offset_x', 0), col.get('offset_y', 0)
1751
+ )
1752
+ tscn.add_node(name, 'CollisionShape2D', parent=parent, properties=props)
1753
+ else:
1754
+ # Default collision shape from sprite dimensions
1755
+ # Prefer frame_width/frame_height (actual sprite size) over width/height
1756
+ w = sprite.get('width') or sprite.get('frame_width') or 32
1757
+ h = sprite.get('height') or sprite.get('frame_height') or 32
1758
+ shape_type = sprite.get('shape', 'box')
1759
+ if shape_type == 'circle':
1760
+ shape_id = tscn.add_sub_resource('CircleShape2D', {
1761
+ 'radius': float(max(w, h)) / 2,
1762
+ })
1763
+ else:
1764
+ shape_id = tscn.add_sub_resource('RectangleShape2D', {
1765
+ 'size': gd_vector2(w, h),
1766
+ })
1767
+ tscn.add_node('CollisionShape2D', 'CollisionShape2D', parent=parent, properties={
1768
+ 'shape': shape_id,
1769
+ })
1770
+
1771
+ def _add_sprite_visual(self, tscn: TscnBuilder, sprite: Dict, parent: str,
1772
+ has_animations: bool = False):
1773
+ """Add the visual representation (Sprite2D) inside a physics body."""
1774
+ if not sprite['src']:
1775
+ return
1776
+
1777
+ res_id = tscn.add_ext_resource('Texture2D', _asset_path(sprite['src']))
1778
+ props = {'texture': f'ExtResource("{res_id}")'}
1779
+
1780
+ if sprite['alpha'] != 1.0:
1781
+ props['modulate'] = f'Color(1, 1, 1, {sprite["alpha"]})'
1782
+ if sprite['scale_x'] != 1.0 or sprite['scale_y'] != 1.0:
1783
+ props['scale'] = gd_vector2(sprite['scale_x'], sprite['scale_y'])
1784
+
1785
+ # Spritesheet slicing: compute hframes from actual texture size
1786
+ fw = sprite.get('frame_width')
1787
+ fh = sprite.get('frame_height')
1788
+ if fw and fh and fw > 0 and fh > 0:
1789
+ src_path = _asset_path(sprite['src'])
1790
+ tex_size = self._get_texture_size(src_path)
1791
+ if tex_size:
1792
+ tw, th = tex_size
1793
+ hf = max(1, tw // fw)
1794
+ vf = max(1, th // fh)
1795
+ props['hframes'] = hf
1796
+ props['vframes'] = vf
1797
+
1798
+ tscn.add_node('Sprite2D', 'Sprite2D', parent=parent, properties=props)
1799
+
1800
+ def _get_texture_size(self, asset_path: str) -> Optional[Tuple[int, int]]:
1801
+ """Get texture dimensions from actual image file on disk."""
1802
+ if not self._output_dir:
1803
+ return None
1804
+ full_path = self._output_dir / asset_path
1805
+ if not full_path.exists():
1806
+ return None
1807
+ try:
1808
+ from PIL import Image
1809
+ with Image.open(full_path) as img:
1810
+ return img.size
1811
+ except Exception:
1812
+ return None
1813
+
1814
+ def _add_tilemap_to_tscn(self, tscn: TscnBuilder, tilemap: Dict, parent: str):
1815
+ """Add tilemap nodes to the TSCN."""
1816
+ tid = tilemap['id']
1817
+ tile_w = tilemap['tile_width']
1818
+ tile_h = tilemap['tile_height']
1819
+
1820
+ if tilemap.get('data_src'):
1821
+ # External data mode: reference .tres tileset + loader script
1822
+ tileset_rid = None
1823
+ if tilemap['src']:
1824
+ tileset_rid = tscn.add_ext_resource('TileSet', _asset_path(tilemap['src']))
1825
+ for layer in tilemap['layers']:
1826
+ layer_name = f'{tid}_{layer["name"]}'
1827
+ props = {
1828
+ 'collision_enabled': layer.get('collision', False),
1829
+ 'z_index': -1, # Render behind player/entities
1830
+ }
1831
+ if tileset_rid:
1832
+ props['tile_set'] = f'ExtResource("{tileset_rid}")'
1833
+ tscn.add_node_with_script(
1834
+ layer_name, 'TileMapLayer',
1835
+ script_path='scripts/tilemap_loader.gd',
1836
+ parent=parent, properties=props
1837
+ )
1838
+ else:
1839
+ # Inline CSV data mode (original behavior)
1840
+ if tilemap['src']:
1841
+ tscn.add_ext_resource('Texture2D', _asset_path(tilemap['src']))
1842
+ for layer in tilemap['layers']:
1843
+ layer_name = f'{tid}_{layer["name"]}'
1844
+ props = {}
1845
+ tscn.add_node(layer_name, 'TileMapLayer', parent=parent, properties=props)
1846
+
1847
+ def _add_camera_to_tscn(self, tscn: TscnBuilder):
1848
+ """Add Camera2D node."""
1849
+ cam = self._camera
1850
+ props = {
1851
+ 'zoom': gd_vector2(cam.get('zoom', 1.0), cam.get('zoom', 1.0)),
1852
+ }
1853
+ if cam.get('offset_x') or cam.get('offset_y'):
1854
+ # Scale camera offset proportionally to viewport height.
1855
+ # .q offset values were calibrated for ~480px PIXI viewport.
1856
+ # For SNES 224px viewport, scale down to avoid hiding ground.
1857
+ default_vp = 480.0 # PIXI default viewport height
1858
+ scale_factor = min(1.0, self._viewport_height / default_vp)
1859
+ offset_x = float(cam.get('offset_x', 0)) * scale_factor
1860
+ offset_y = float(cam.get('offset_y', 0)) * scale_factor
1861
+ props['offset'] = gd_vector2(offset_x, offset_y)
1862
+
1863
+ follow_target = cam.get('follow')
1864
+ if follow_target:
1865
+ # Camera follows a sprite — attach as child of that sprite
1866
+ # Godot Camera2D inherits parent transform, so it auto-follows
1867
+ tscn.add_node_with_script(
1868
+ 'Camera2D', 'Camera2D',
1869
+ script_path='scripts/camera_follow.gd',
1870
+ parent=follow_target, properties=props
1871
+ )
1872
+ else:
1873
+ tscn.add_node('Camera2D', 'Camera2D', parent='.', properties=props)
1874
+
1875
+ def _add_particle_to_tscn(self, tscn: TscnBuilder, particle: Dict, parent: str):
1876
+ """Add GPUParticles2D node."""
1877
+ pid = particle['id']
1878
+ props = {
1879
+ 'amount': particle.get('count', 20),
1880
+ 'lifetime': particle.get('lifetime', 1.0),
1881
+ 'emitting': False, # Triggered by events
1882
+ }
1883
+ tscn.add_node(pid, 'GPUParticles2D', parent=parent, properties=props)
1884
+
1885
+ def _add_hud_to_tscn(self, tscn: TscnBuilder):
1886
+ """Add HUD (CanvasLayer + Controls)."""
1887
+ tscn.add_node_with_script(
1888
+ 'HUD', 'CanvasLayer',
1889
+ script_path='scripts/hud_manager.gd',
1890
+ parent='.'
1891
+ )
1892
+
1893
+ if self._has_tile_hud():
1894
+ # Tile-based HUD: script handles all drawing, no Label children needed
1895
+ return
1896
+
1897
+ # Legacy HTML-based HUD: create Label children
1898
+ label_index = 0
1899
+ for hud in self._huds:
1900
+ for child in hud.get('children', []):
1901
+ if child.get('type') == 'html':
1902
+ tag = child.get('tag', 'div')
1903
+ attrs = child.get('attrs', {})
1904
+ label_name = attrs.get('id', f'label_{label_index}')
1905
+ text = ''
1906
+ for sub in child.get('children', []):
1907
+ if isinstance(sub, TextNode):
1908
+ text = sub.content
1909
+ elif hasattr(sub, 'content'):
1910
+ text = sub.content
1911
+
1912
+ props = {
1913
+ 'text': f'"{text}"' if text else '""',
1914
+ }
1915
+
1916
+ # Position based on hud position
1917
+ pos = hud.get('position', 'top-left')
1918
+ margin = 10
1919
+ if 'right' in pos:
1920
+ props['offset_left'] = -200.0
1921
+ props['offset_right'] = float(-margin)
1922
+ else:
1923
+ props['offset_left'] = float(margin)
1924
+ props['offset_right'] = 200.0
1925
+ if 'bottom' in pos:
1926
+ props['offset_top'] = -40.0
1927
+ props['offset_bottom'] = 0.0
1928
+ else:
1929
+ props['offset_top'] = float(margin)
1930
+ props['offset_bottom'] = 40.0
1931
+
1932
+ tscn.add_node(label_name, 'Label', parent='HUD', properties=props)
1933
+ label_index += 1
1934
+
1935
+ def _add_instance_to_tscn(self, tscn: TscnBuilder, inst: Dict, parent: str):
1936
+ """Add an instantiated prefab to the TSCN."""
1937
+ prefab_name = inst['prefab']
1938
+ inst_id = inst.get('id') or f'{prefab_name}_{id(inst)}'
1939
+ x = float(inst['x'])
1940
+ y = float(inst['y'])
1941
+
1942
+ # Reference the prefab scene
1943
+ res_id = tscn.add_ext_resource('PackedScene', f'prefabs/{prefab_name}.tscn')
1944
+ tscn.add_node(inst_id, '', parent=parent, properties={
1945
+ 'position': gd_vector2(x, y),
1946
+ }, instance=f'ExtResource("{res_id}")')
1947
+
1948
+ def _write_prefab_scenes(self, prefabs_dir: Path, scripts_dir: Path):
1949
+ """Generate .tscn files for each prefab."""
1950
+ for name, prefab in self._prefabs.items():
1951
+ tscn = TscnBuilder()
1952
+
1953
+ # Determine root node type based on prefab semantics
1954
+ entity_type = prefab.entity_type
1955
+ movement = prefab.movement
1956
+ collectible = prefab.collectible
1957
+ body_type = self._get_prefab_body_type(prefab)
1958
+
1959
+ # Get tag and animations from prefab's sprite child
1960
+ prefab_tag = None
1961
+ prefab_anims = []
1962
+ for child in prefab.children:
1963
+ if isinstance(child, SpriteNode):
1964
+ if child.tag:
1965
+ prefab_tag = child.tag
1966
+ # Collect animations from sprite children
1967
+ for anim_child in child.children:
1968
+ if isinstance(anim_child, AnimationNode):
1969
+ prefab_anims.append(anim_child)
1970
+ root_props = {}
1971
+ if prefab_tag:
1972
+ root_props['metadata/tag'] = prefab_tag # TscnBuilder auto-quotes
1973
+
1974
+ # Determine if this prefab needs a script
1975
+ has_enemy_ai = False
1976
+ has_animation = len(prefab_anims) > 0
1977
+
1978
+ # Check if there's an EnemyNode for this prefab
1979
+ enemy_def = self._get_enemy_for_prefab(name)
1980
+
1981
+ if body_type == 'dynamic':
1982
+ # Detect if entity needs patrol AI: explicit movement, enemy tag, or EnemyNode
1983
+ has_movement = movement and movement != 'none'
1984
+ has_enemy_tag = prefab_tag == 'enemy' or entity_type == 'enemy'
1985
+ has_enemy_ai = has_movement or has_enemy_tag or enemy_def is not None
1986
+ if has_enemy_ai:
1987
+ # Enemy/moving entity — attach patrol script (includes animation)
1988
+ script_name = f'prefab_{name}.gd'
1989
+ tscn.add_node_with_script(
1990
+ name, 'CharacterBody2D',
1991
+ script_path=f'scripts/{script_name}',
1992
+ properties=root_props
1993
+ )
1994
+ if enemy_def:
1995
+ self._write_enemy_script(scripts_dir, name, prefab, enemy_def, prefab_anims)
1996
+ else:
1997
+ self._write_prefab_script(scripts_dir, name, prefab, prefab_anims)
1998
+ else:
1999
+ tscn.add_node(name, 'CharacterBody2D', properties=root_props)
2000
+ elif body_type == 'static':
2001
+ # Detect qblock by tag even without explicit content/hits attrs
2002
+ is_qblock = prefab_tag == 'qblock'
2003
+ has_block_content = (prefab.content is not None or
2004
+ (prefab.hits is not None and prefab.hits > 0) or
2005
+ is_qblock)
2006
+ has_breakable = prefab.breakable
2007
+
2008
+ is_flying = getattr(prefab, 'flying', False) or False
2009
+ if has_block_content and is_flying:
2010
+ script_name = f'prefab_{name}.gd'
2011
+ tscn.add_node_with_script(
2012
+ name, 'StaticBody2D',
2013
+ script_path=f'scripts/{script_name}',
2014
+ properties=root_props
2015
+ )
2016
+ self._write_flying_block_script(scripts_dir, name, prefab, prefab_anims)
2017
+ elif has_block_content:
2018
+ script_name = f'prefab_{name}.gd'
2019
+ tscn.add_node_with_script(
2020
+ name, 'StaticBody2D',
2021
+ script_path=f'scripts/{script_name}',
2022
+ properties=root_props
2023
+ )
2024
+ self._write_block_script(scripts_dir, name, prefab, prefab_anims)
2025
+ elif has_breakable:
2026
+ script_name = f'prefab_{name}.gd'
2027
+ tscn.add_node_with_script(
2028
+ name, 'StaticBody2D',
2029
+ script_path=f'scripts/{script_name}',
2030
+ properties=root_props
2031
+ )
2032
+ self._write_breakable_block_script(scripts_dir, name, prefab, prefab_anims)
2033
+ elif has_animation:
2034
+ script_name = f'prefab_{name}.gd'
2035
+ tscn.add_node_with_script(
2036
+ name, 'StaticBody2D',
2037
+ script_path=f'scripts/{script_name}',
2038
+ properties=root_props
2039
+ )
2040
+ self._write_animator_script(scripts_dir, name, 'StaticBody2D', prefab_anims)
2041
+ else:
2042
+ tscn.add_node(name, 'StaticBody2D', properties=root_props)
2043
+ elif collectible or (entity_type in ('item', 'goal')) or prefab.checkpoint:
2044
+ has_collectible = prefab.collectible
2045
+ has_checkpoint = prefab.checkpoint
2046
+
2047
+ if has_collectible:
2048
+ script_name = f'prefab_{name}.gd'
2049
+ tscn.add_node_with_script(
2050
+ name, 'Area2D',
2051
+ script_path=f'scripts/{script_name}',
2052
+ properties=root_props
2053
+ )
2054
+ self._write_collectible_script(scripts_dir, name, prefab, prefab_tag, prefab_anims)
2055
+ elif has_checkpoint:
2056
+ script_name = f'prefab_{name}.gd'
2057
+ tscn.add_node_with_script(
2058
+ name, 'Area2D',
2059
+ script_path=f'scripts/{script_name}',
2060
+ properties=root_props
2061
+ )
2062
+ self._write_checkpoint_script(scripts_dir, name, prefab, prefab_anims)
2063
+ elif has_animation:
2064
+ script_name = f'prefab_{name}.gd'
2065
+ tscn.add_node_with_script(
2066
+ name, 'Area2D',
2067
+ script_path=f'scripts/{script_name}',
2068
+ properties=root_props
2069
+ )
2070
+ self._write_animator_script(scripts_dir, name, 'Area2D', prefab_anims)
2071
+ else:
2072
+ tscn.add_node(name, 'Area2D', properties=root_props)
2073
+ else:
2074
+ if enemy_def is not None:
2075
+ # Sensor/hazard enemy (e.g., piranha plant with emerge AI)
2076
+ script_name = f'prefab_{name}.gd'
2077
+ tscn.add_node_with_script(
2078
+ name, 'Node2D',
2079
+ script_path=f'scripts/{script_name}',
2080
+ properties=root_props
2081
+ )
2082
+ self._write_enemy_script(scripts_dir, name, prefab, enemy_def, prefab_anims)
2083
+ elif has_animation:
2084
+ script_name = f'prefab_{name}.gd'
2085
+ tscn.add_node_with_script(
2086
+ name, 'Node2D',
2087
+ script_path=f'scripts/{script_name}',
2088
+ properties=root_props
2089
+ )
2090
+ self._write_animator_script(scripts_dir, name, 'Node2D', prefab_anims)
2091
+ else:
2092
+ tscn.add_node(name, 'Node2D', properties=root_props)
2093
+
2094
+ # Process prefab children (usually a sprite)
2095
+ child_idx = 0
2096
+ for child in prefab.children:
2097
+ if isinstance(child, SpriteNode):
2098
+ sprite_data = self._sprite_to_dict(child)
2099
+ # Ensure child has a valid node name (empty names crash Godot)
2100
+ if not sprite_data['id']:
2101
+ suffix = f'_{child_idx}' if child_idx > 0 else ''
2102
+ sprite_data['id'] = f'{name}_body{suffix}'
2103
+ child_idx += 1
2104
+ if body_type in ('dynamic', 'static'):
2105
+ # Add visual + collision as children
2106
+ self._add_sprite_visual(tscn, sprite_data, parent='.')
2107
+ self._add_collision_shape(tscn, sprite_data, parent='.')
2108
+ elif body_type is None and sprite_data.get('sensor'):
2109
+ # Root is already Area2D — add visual + collision directly (no nested Area2D)
2110
+ self._add_collision_shape(tscn, sprite_data, parent='.')
2111
+ self._add_sprite_visual(tscn, sprite_data, parent='.')
2112
+ else:
2113
+ self._add_sprite_to_tscn(tscn, sprite_data, parent='.')
2114
+
2115
+ content = tscn.build()
2116
+ (prefabs_dir / f'{name}.tscn').write_text(content, encoding='utf-8')
2117
+
2118
+ def _get_prefab_body_type(self, prefab: PrefabNode) -> Optional[str]:
2119
+ """Determine the physics body type for a prefab."""
2120
+ # Check entity type
2121
+ if prefab.entity_type == 'enemy':
2122
+ return 'dynamic'
2123
+ if prefab.entity_type == 'block':
2124
+ return 'static'
2125
+ if prefab.entity_type in ('item', 'goal'):
2126
+ return None # Area2D
2127
+
2128
+ # Check children for body hints
2129
+ for child in prefab.children:
2130
+ if isinstance(child, SpriteNode):
2131
+ # Sensors should be Area2D, not StaticBody2D
2132
+ if child.sensor:
2133
+ return None # Area2D
2134
+ if child.body:
2135
+ return child.body
2136
+ return None
2137
+
2138
+ def _sprite_to_dict(self, node: SpriteNode) -> Dict:
2139
+ """Convert a SpriteNode to a dict (reuse _process_sprite format)."""
2140
+ if node.src:
2141
+ self._assets.add(node.src)
2142
+ return {
2143
+ 'id': node.sprite_id,
2144
+ 'src': node.src,
2145
+ 'x': node.x, 'y': node.y,
2146
+ 'width': node.width, 'height': node.height,
2147
+ 'anchor_x': node.anchor_x, 'anchor_y': node.anchor_y,
2148
+ 'rotation': node.rotation,
2149
+ 'scale_x': node.scale_x, 'scale_y': node.scale_y,
2150
+ 'alpha': node.alpha, 'visible': node.visible,
2151
+ 'color': node.color, 'tag': node.tag, 'layer': node.layer,
2152
+ 'frame_width': node.frame_width, 'frame_height': node.frame_height,
2153
+ 'body': node.body, 'shape': node.shape,
2154
+ 'bounce': node.bounce, 'friction': node.friction,
2155
+ 'mass': node.mass, 'sensor': node.sensor,
2156
+ 'controls': node.controls, 'speed': node.speed,
2157
+ 'jump_force': node.jump_force,
2158
+ 'gravity_up': node.gravity_up, 'gravity_down': node.gravity_down,
2159
+ 'jump_hold_boost': node.jump_hold_boost,
2160
+ 'coyote_frames': node.coyote_frames,
2161
+ 'max_fall_speed': node.max_fall_speed,
2162
+ 'animations': [], 'colliders': [], 'on_collisions': [],
2163
+ 'behaviors': [], 'children': [],
2164
+ }
2165
+
2166
+ def _write_animator_script(self, scripts_dir: Path, name: str,
2167
+ base_class: str, anims: list):
2168
+ """Generate a simple sprite animator script for a prefab."""
2169
+ # Get the first auto-play animation's frames and speed
2170
+ frames = [0, 1, 2, 3]
2171
+ anim_speed = 0.15
2172
+ for anim in anims:
2173
+ if getattr(anim, 'auto_play', False) or True: # Use first anim
2174
+ parsed = self._parse_frame_range(getattr(anim, 'frames', '0'))
2175
+ if parsed:
2176
+ frames = parsed
2177
+ anim_speed = float(getattr(anim, 'speed', 0.15) or 0.15)
2178
+ break
2179
+
2180
+ content = SPRITE_ANIMATOR_GD.format(
2181
+ base_class=base_class,
2182
+ frames=str(frames),
2183
+ speed=f'{anim_speed:.2f}',
2184
+ )
2185
+ (scripts_dir / f'prefab_{name}.gd').write_text(content, encoding='utf-8')
2186
+
2187
+ def _write_prefab_script(self, scripts_dir: Path, name: str,
2188
+ prefab: PrefabNode, anims: list = None):
2189
+ """Generate a GDScript for a prefab with AI/behavior."""
2190
+ gd = GdScriptBuilder()
2191
+ gd.extends('CharacterBody2D')
2192
+ gd.blank()
2193
+
2194
+ # Use move_speed directly as px/s (no conversion if >= 10)
2195
+ speed = float(prefab.move_speed or 50.0)
2196
+ if speed < 10:
2197
+ speed = speed * 60.0 # Convert frame-based speed to px/s
2198
+ gravity = SMW_GRAVITY
2199
+
2200
+ # Get collision settings from child sprite
2201
+ child_collision_layer = None
2202
+ child_collision_mask = None
2203
+ for child in prefab.children:
2204
+ if isinstance(child, SpriteNode):
2205
+ if child.collision_layer is not None:
2206
+ child_collision_layer = child.collision_layer
2207
+ if child.collision_mask is not None:
2208
+ child_collision_mask = child.collision_mask
2209
+ break
2210
+
2211
+ gd.export_var('patrol_speed', 'float', f'{speed:.1f}')
2212
+ gd.export_var('turn_on_edge', 'bool', gd_bool(prefab.turn_on_edge or False))
2213
+ gd.var('_direction', '-1.0' if (getattr(prefab, 'facing', None) or 'left') == 'left' else '1.0')
2214
+ gd.blank()
2215
+
2216
+ # Turn cooldown (anti-oscillation)
2217
+ has_turn_cooldown = prefab.turn_cooldown is not None and prefab.turn_cooldown > 0
2218
+ if has_turn_cooldown:
2219
+ gd.var('_turn_cooldown', f'{prefab.turn_cooldown:.2f}', 'float')
2220
+ gd.var('_turn_timer', '0.0', 'float')
2221
+ gd.blank()
2222
+
2223
+ # Squish mechanic: health >= 2 + defeated_by == "stomp"
2224
+ has_squish = (prefab.health is not None and prefab.health >= 2
2225
+ and prefab.defeated_by == 'stomp')
2226
+ # Get frame_height from child sprite for squish offset calculation
2227
+ prefab_frame_h = 32 # default
2228
+ for child in prefab.children:
2229
+ if isinstance(child, SpriteNode):
2230
+ prefab_frame_h = child.frame_height or child.height or 32
2231
+ break
2232
+ if has_squish:
2233
+ gd.var('_health', str(prefab.health), 'int')
2234
+ gd.var('_is_squished', 'false', 'bool')
2235
+ gd.var('_squish_speed_mult', '1.5', 'float')
2236
+ gd.var('frame_h', str(prefab_frame_h), 'float')
2237
+ gd.blank()
2238
+
2239
+ # Animation variables
2240
+ anim_frames = [0] # Default to single frame (no animation)
2241
+ anim_speed = 0.15
2242
+ if anims:
2243
+ for anim in anims:
2244
+ parsed = self._parse_frame_range(getattr(anim, 'frames', '0'))
2245
+ if parsed:
2246
+ anim_frames = parsed
2247
+ anim_speed = float(getattr(anim, 'speed', 0.15) or 0.15)
2248
+ break
2249
+ gd.var('_anim_timer', '0.0', 'float')
2250
+ gd.var('_anim_frames', str(anim_frames), 'Array')
2251
+ gd.var('_anim_idx', '0', 'int')
2252
+ gd.var('_anim_speed', f'{anim_speed:.2f}', 'float')
2253
+ gd.blank()
2254
+
2255
+ # _ready: set collision layers
2256
+ if child_collision_layer is not None or child_collision_mask is not None:
2257
+ gd.func('_ready')
2258
+ if child_collision_layer is not None:
2259
+ gd.assign('collision_layer', str(self._resolve_collision_value(child_collision_layer)))
2260
+ if child_collision_mask is not None:
2261
+ gd.assign('collision_mask', str(self._resolve_collision_value(child_collision_mask)))
2262
+ gd.func_close()
2263
+ gd.blank()
2264
+
2265
+ # _physics_process
2266
+ gd.func('_physics_process', 'delta: float')
2267
+ gd.if_block('not is_on_floor()')
2268
+ gd.line(f'velocity.y += {gravity} * delta')
2269
+ gd.else_block()
2270
+ gd.assign('velocity.y', '0')
2271
+ gd.block_close()
2272
+ gd.blank()
2273
+
2274
+ # Turn cooldown timer
2275
+ if has_turn_cooldown:
2276
+ gd.if_block('_turn_timer > 0')
2277
+ gd.line('_turn_timer -= delta')
2278
+ gd.block_close()
2279
+ gd.blank()
2280
+
2281
+ # Velocity
2282
+ gd.assign('velocity.x', '_direction * patrol_speed')
2283
+ gd.blank()
2284
+
2285
+ # Wall turn with cooldown
2286
+ if has_turn_cooldown:
2287
+ gd.if_block('is_on_wall() and _turn_timer <= 0')
2288
+ gd.line('_direction *= -1')
2289
+ gd.assign('_turn_timer', '_turn_cooldown')
2290
+ gd.block_close()
2291
+ else:
2292
+ gd.if_block('is_on_wall()')
2293
+ gd.line('_direction *= -1')
2294
+ gd.block_close()
2295
+ gd.blank()
2296
+
2297
+ gd.line('move_and_slide()')
2298
+ gd.blank()
2299
+ gd.comment('Flip sprite to face movement direction')
2300
+ gd.if_block('has_node("Sprite2D")')
2301
+ gd.line('$Sprite2D.flip_h = _direction > 0')
2302
+ gd.block_close()
2303
+ gd.blank()
2304
+ gd.comment('Animate sprite')
2305
+ gd.line('_anim_timer += delta')
2306
+ gd.if_block('_anim_timer >= _anim_speed')
2307
+ gd.assign('_anim_timer', '0.0')
2308
+ gd.line('_anim_idx = (_anim_idx + 1) % _anim_frames.size()')
2309
+ gd.if_block('has_node("Sprite2D")')
2310
+ gd.line('$Sprite2D.frame = _anim_frames[_anim_idx]')
2311
+ gd.block_close()
2312
+ gd.block_close()
2313
+ gd.func_close()
2314
+ gd.blank()
2315
+
2316
+ # Stomp handler (squish mechanic)
2317
+ if has_squish:
2318
+ stomp_bounce = prefab.stomp_bounce or -200
2319
+ reward_str = prefab.reward or ''
2320
+ reward_kill_str = prefab.reward_kill or ''
2321
+ gd.func('stomp')
2322
+ gd.line('_health -= 1')
2323
+ gd.if_block('_health <= 0')
2324
+ # Final kill
2325
+ if reward_kill_str:
2326
+ parts = reward_kill_str.split(':')
2327
+ if len(parts) == 2:
2328
+ gd.line(f'QuantumEventBus.emit_event("enemy-killed", {{"score": {parts[1]}, "other": self}})')
2329
+ gd.line('queue_free()')
2330
+ gd.else_block()
2331
+ # Squish (half height, speed up)
2332
+ gd.assign('_is_squished', 'true')
2333
+ gd.comment('Squish visual + reposition body')
2334
+ gd.if_block('has_node("Sprite2D")')
2335
+ gd.line('$Sprite2D.scale.y = 0.5')
2336
+ gd.line('$Sprite2D.position.y += frame_h * 0.25')
2337
+ gd.block_close()
2338
+ gd.line('global_position.y += frame_h * 0.25')
2339
+ gd.comment('Shrink collision to match squished sprite')
2340
+ gd.if_block('has_node("CollisionShape2D")')
2341
+ gd.line('var shape = $CollisionShape2D.shape.duplicate()')
2342
+ gd.line('$CollisionShape2D.shape = shape')
2343
+ gd.if_block('shape is RectangleShape2D')
2344
+ gd.line('shape.size.y *= 0.5')
2345
+ gd.block_close()
2346
+ gd.block_close()
2347
+ gd.comment('Speed up after hit')
2348
+ gd.line('patrol_speed *= _squish_speed_mult')
2349
+ if reward_str:
2350
+ parts = reward_str.split(':')
2351
+ if len(parts) == 2:
2352
+ gd.line(f'QuantumEventBus.emit_event("enemy-stomped", {{"score": {parts[1]}, "other": self}})')
2353
+ gd.block_close()
2354
+ gd.func_close()
2355
+ gd.blank()
2356
+
2357
+ # die()
2358
+ gd.func('die')
2359
+ gd.line('QuantumEventBus.emit_event("enemy-killed", {"other": self})')
2360
+ gd.line('queue_free()')
2361
+ gd.func_close()
2362
+ gd.blank()
2363
+
2364
+ # set_patrol()
2365
+ gd.func('set_patrol', 'speed: float')
2366
+ gd.assign('patrol_speed', 'speed')
2367
+ gd.func_close()
2368
+
2369
+ (scripts_dir / f'prefab_{name}.gd').write_text(gd.build(), encoding='utf-8')
2370
+
2371
+ def _get_enemy_for_prefab(self, name: str) -> Optional[EnemyNode]:
2372
+ """Find EnemyNode definition for a given prefab name."""
2373
+ for enemy in self._enemies:
2374
+ if enemy.prefab == name:
2375
+ return enemy
2376
+ return None
2377
+
2378
+ def _write_enemy_script(self, scripts_dir: Path, name: str,
2379
+ prefab: PrefabNode, enemy: EnemyNode,
2380
+ anims: list = None):
2381
+ """Generate a GDScript for an enemy prefab using EnemyNode data."""
2382
+ ai = enemy.ai
2383
+ ai_type = ai.ai_type if ai else 'patrol'
2384
+
2385
+ # Dispatch to specialized generators for non-patrol AI types
2386
+ if ai_type == 'charge':
2387
+ self._write_charge_enemy_script(scripts_dir, name, prefab, enemy, anims)
2388
+ return
2389
+ if ai_type == 'emerge':
2390
+ self._write_emerge_enemy_script(scripts_dir, name, prefab, enemy, anims)
2391
+ return
2392
+
2393
+ gd = GdScriptBuilder()
2394
+ gd.extends('CharacterBody2D')
2395
+ gd.blank()
2396
+
2397
+ # AI data
2398
+ speed = ai.speed if ai else 30.0
2399
+ if speed < 10:
2400
+ speed = speed * 60.0
2401
+ facing = (ai.facing if ai else 'left') or 'left'
2402
+ turn_on_edge = ai.turn_on_edge if ai else False
2403
+ gravity = SMW_GRAVITY
2404
+
2405
+ # Get collision settings from child sprite
2406
+ child_collision_layer = None
2407
+ child_collision_mask = None
2408
+ for child in prefab.children:
2409
+ if isinstance(child, SpriteNode):
2410
+ if child.collision_layer is not None:
2411
+ child_collision_layer = child.collision_layer
2412
+ if child.collision_mask is not None:
2413
+ child_collision_mask = child.collision_mask
2414
+ break
2415
+
2416
+ gd.export_var('patrol_speed', 'float', f'{speed:.1f}')
2417
+ gd.export_var('turn_on_edge', 'bool', gd_bool(turn_on_edge))
2418
+ gd.var('_direction', '-1.0' if facing == 'left' else '1.0')
2419
+ gd.blank()
2420
+
2421
+ # Turn cooldown
2422
+ turn_cooldown = ai.turn_cooldown if ai else None
2423
+ has_turn_cooldown = turn_cooldown is not None and turn_cooldown > 0
2424
+ if has_turn_cooldown:
2425
+ gd.var('_turn_cooldown', f'{turn_cooldown:.2f}', 'float')
2426
+ gd.var('_turn_timer', '0.0', 'float')
2427
+ gd.blank()
2428
+
2429
+ # Squish mechanic from defeat data
2430
+ defeat = enemy.defeat
2431
+ has_squish = (defeat is not None and defeat.health >= 2
2432
+ and defeat.by == 'stomp')
2433
+ squish_speed_mult = 1.5
2434
+ # Get frame_height from child sprite for squish offset calculation
2435
+ enemy_frame_h = 32 # default
2436
+ for child in prefab.children:
2437
+ if isinstance(child, SpriteNode):
2438
+ enemy_frame_h = child.frame_height or child.height or 32
2439
+ break
2440
+ if has_squish:
2441
+ if defeat.on_hit and defeat.on_hit.speed_multiply:
2442
+ squish_speed_mult = defeat.on_hit.speed_multiply
2443
+ gd.var('_health', str(defeat.health), 'int')
2444
+ gd.var('_is_squished', 'false', 'bool')
2445
+ gd.var('_squish_speed_mult', f'{squish_speed_mult}', 'float')
2446
+ gd.var('frame_h', str(enemy_frame_h), 'float')
2447
+ gd.blank()
2448
+
2449
+ # Animation variables
2450
+ anim_frames = [0] # Default to single frame (no animation)
2451
+ anim_speed = 0.15
2452
+ if anims:
2453
+ for anim in anims:
2454
+ parsed = self._parse_frame_range(getattr(anim, 'frames', '0'))
2455
+ if parsed:
2456
+ anim_frames = parsed
2457
+ anim_speed = float(getattr(anim, 'speed', 0.15) or 0.15)
2458
+ break
2459
+ gd.var('_anim_timer', '0.0', 'float')
2460
+ gd.var('_anim_frames', str(anim_frames), 'Array')
2461
+ gd.var('_anim_idx', '0', 'int')
2462
+ gd.var('_anim_speed', f'{anim_speed:.2f}', 'float')
2463
+ gd.blank()
2464
+
2465
+ # _ready: set collision layers
2466
+ if child_collision_layer is not None or child_collision_mask is not None:
2467
+ gd.func('_ready')
2468
+ if child_collision_layer is not None:
2469
+ gd.assign('collision_layer', str(self._resolve_collision_value(child_collision_layer)))
2470
+ if child_collision_mask is not None:
2471
+ gd.assign('collision_mask', str(self._resolve_collision_value(child_collision_mask)))
2472
+ gd.func_close()
2473
+ gd.blank()
2474
+
2475
+ # _physics_process
2476
+ gd.func('_physics_process', 'delta: float')
2477
+ gd.if_block('not is_on_floor()')
2478
+ gd.line(f'velocity.y += {gravity} * delta')
2479
+ gd.else_block()
2480
+ gd.assign('velocity.y', '0')
2481
+ gd.block_close()
2482
+ gd.blank()
2483
+
2484
+ if has_turn_cooldown:
2485
+ gd.if_block('_turn_timer > 0')
2486
+ gd.line('_turn_timer -= delta')
2487
+ gd.block_close()
2488
+ gd.blank()
2489
+
2490
+ gd.assign('velocity.x', '_direction * patrol_speed')
2491
+ gd.blank()
2492
+
2493
+ if has_turn_cooldown:
2494
+ gd.if_block('is_on_wall() and _turn_timer <= 0')
2495
+ gd.line('_direction *= -1')
2496
+ gd.assign('_turn_timer', '_turn_cooldown')
2497
+ gd.block_close()
2498
+ else:
2499
+ gd.if_block('is_on_wall()')
2500
+ gd.line('_direction *= -1')
2501
+ gd.block_close()
2502
+ gd.blank()
2503
+
2504
+ gd.line('move_and_slide()')
2505
+ gd.blank()
2506
+ gd.comment('Flip sprite to face movement direction')
2507
+ gd.if_block('has_node("Sprite2D")')
2508
+ gd.line('$Sprite2D.flip_h = _direction > 0')
2509
+ gd.block_close()
2510
+ gd.blank()
2511
+ gd.comment('Animate sprite')
2512
+ gd.line('_anim_timer += delta')
2513
+ gd.if_block('_anim_timer >= _anim_speed')
2514
+ gd.assign('_anim_timer', '0.0')
2515
+ gd.line('_anim_idx = (_anim_idx + 1) % _anim_frames.size()')
2516
+ gd.if_block('has_node("Sprite2D")')
2517
+ gd.line('$Sprite2D.frame = _anim_frames[_anim_idx]')
2518
+ gd.block_close()
2519
+ gd.block_close()
2520
+ gd.func_close()
2521
+ gd.blank()
2522
+
2523
+ # Stomp handler (squish mechanic) - data-driven from EnemyNode
2524
+ if has_squish:
2525
+ stomp_bounce = defeat.bounce or -200
2526
+ hit_score = defeat.on_hit.score if defeat.on_hit else 0
2527
+ kill_score = defeat.on_kill.score if defeat.on_kill else 0
2528
+ gd.func('stomp')
2529
+ gd.line('_health -= 1')
2530
+ gd.if_block('_health <= 0')
2531
+ if kill_score:
2532
+ gd.line(f'QuantumEventBus.emit_event("enemy-killed", {{"score": {kill_score}, "other": self}})')
2533
+ else:
2534
+ gd.line('QuantumEventBus.emit_event("enemy-killed", {"other": self})')
2535
+ gd.line('queue_free()')
2536
+ gd.else_block()
2537
+ gd.assign('_is_squished', 'true')
2538
+ gd.comment('Squish visual + reposition body')
2539
+ gd.if_block('has_node("Sprite2D")')
2540
+ gd.line('$Sprite2D.scale.y = 0.5')
2541
+ gd.line('$Sprite2D.position.y += frame_h * 0.25')
2542
+ gd.block_close()
2543
+ gd.line('global_position.y += frame_h * 0.25')
2544
+ gd.comment('Shrink collision to match squished sprite')
2545
+ gd.if_block('has_node("CollisionShape2D")')
2546
+ gd.line('var shape = $CollisionShape2D.shape.duplicate()')
2547
+ gd.line('$CollisionShape2D.shape = shape')
2548
+ gd.if_block('shape is RectangleShape2D')
2549
+ gd.line('shape.size.y *= 0.5')
2550
+ gd.block_close()
2551
+ gd.block_close()
2552
+ gd.comment('Speed up after hit')
2553
+ gd.line('patrol_speed *= _squish_speed_mult')
2554
+ if hit_score:
2555
+ gd.line(f'QuantumEventBus.emit_event("enemy-stomped", {{"score": {hit_score}, "other": self}})')
2556
+ gd.block_close()
2557
+ gd.func_close()
2558
+ gd.blank()
2559
+
2560
+ # die()
2561
+ gd.func('die')
2562
+ gd.line('QuantumEventBus.emit_event("enemy-killed", {"other": self})')
2563
+ gd.line('queue_free()')
2564
+ gd.func_close()
2565
+ gd.blank()
2566
+
2567
+ # set_patrol()
2568
+ gd.func('set_patrol', 'speed: float')
2569
+ gd.assign('patrol_speed', 'speed')
2570
+ gd.func_close()
2571
+
2572
+ (scripts_dir / f'prefab_{name}.gd').write_text(gd.build(), encoding='utf-8')
2573
+
2574
+ def _write_charge_enemy_script(self, scripts_dir: Path, name: str,
2575
+ prefab: PrefabNode, enemy: EnemyNode,
2576
+ anims: list = None):
2577
+ """Generate GDScript for charge AI enemy (Banzai Bill - horizontal flight, no gravity)."""
2578
+ gd = GdScriptBuilder()
2579
+ gd.extends('CharacterBody2D')
2580
+ gd.blank()
2581
+
2582
+ ai = enemy.ai
2583
+ speed = ai.speed if ai else 90.0
2584
+ if speed < 10:
2585
+ speed = speed * 60.0
2586
+ facing = (ai.facing if ai else 'left') or 'left'
2587
+
2588
+ # Get collision settings
2589
+ child_collision_layer = None
2590
+ child_collision_mask = None
2591
+ for child in prefab.children:
2592
+ if isinstance(child, SpriteNode):
2593
+ if child.collision_layer is not None:
2594
+ child_collision_layer = child.collision_layer
2595
+ if child.collision_mask is not None:
2596
+ child_collision_mask = child.collision_mask
2597
+ break
2598
+
2599
+ gd.export_var('charge_speed', 'float', f'{speed:.1f}')
2600
+ gd.var('_direction', '-1.0' if facing == 'left' else '1.0')
2601
+ gd.blank()
2602
+
2603
+ # Defeat data
2604
+ defeat = enemy.defeat
2605
+ has_stomp = defeat is not None and defeat.by == 'stomp'
2606
+ if has_stomp:
2607
+ gd.var('_health', str(defeat.health), 'int')
2608
+ gd.blank()
2609
+
2610
+ # Animation variables
2611
+ anim_frames = [0] # Default to single frame (no animation)
2612
+ anim_speed = 0.15
2613
+ if anims:
2614
+ for anim in anims:
2615
+ parsed = self._parse_frame_range(getattr(anim, 'frames', '0'))
2616
+ if parsed:
2617
+ anim_frames = parsed
2618
+ anim_speed = float(getattr(anim, 'speed', 0.15) or 0.15)
2619
+ break
2620
+ gd.var('_anim_timer', '0.0', 'float')
2621
+ gd.var('_anim_frames', str(anim_frames), 'Array')
2622
+ gd.var('_anim_idx', '0', 'int')
2623
+ gd.var('_anim_speed', f'{anim_speed:.2f}', 'float')
2624
+ gd.var('_launch_sound_played', 'false', 'bool')
2625
+ gd.blank()
2626
+
2627
+ # _ready
2628
+ gd.func('_ready')
2629
+ if child_collision_layer is not None:
2630
+ gd.assign('collision_layer', str(self._resolve_collision_value(child_collision_layer)))
2631
+ if child_collision_mask is not None:
2632
+ gd.assign('collision_mask', str(self._resolve_collision_value(child_collision_mask)))
2633
+ gd.func_close()
2634
+ gd.blank()
2635
+
2636
+ # _physics_process: NO gravity, horizontal flight only
2637
+ gd.func('_physics_process', 'delta: float')
2638
+ gd.comment('Launch sound when entering viewport')
2639
+ gd.if_block('not _launch_sound_played')
2640
+ gd.line('var camera = get_viewport().get_camera_2d()')
2641
+ gd.if_block('camera')
2642
+ gd.line('var vp_w = get_viewport_rect().size.x')
2643
+ gd.if_block('global_position.x < camera.global_position.x + vp_w + 100')
2644
+ gd.assign('_launch_sound_played', 'true')
2645
+ gd.line('QuantumEventBus.emit_event("banzai-launch", {"other": self})')
2646
+ gd.block_close()
2647
+ gd.block_close()
2648
+ gd.block_close()
2649
+ gd.blank()
2650
+ gd.comment('Charge AI: horizontal flight, no gravity')
2651
+ gd.assign('velocity.x', '_direction * charge_speed')
2652
+ gd.assign('velocity.y', '0')
2653
+ gd.line('move_and_slide()')
2654
+ gd.blank()
2655
+ gd.comment('Flip sprite to face movement direction')
2656
+ gd.if_block('has_node("Sprite2D")')
2657
+ gd.line('$Sprite2D.flip_h = _direction > 0')
2658
+ gd.block_close()
2659
+ gd.blank()
2660
+ gd.comment('Animate sprite')
2661
+ gd.line('_anim_timer += delta')
2662
+ gd.if_block('_anim_timer >= _anim_speed')
2663
+ gd.assign('_anim_timer', '0.0')
2664
+ gd.line('_anim_idx = (_anim_idx + 1) % _anim_frames.size()')
2665
+ gd.if_block('has_node("Sprite2D")')
2666
+ gd.line('$Sprite2D.frame = _anim_frames[_anim_idx]')
2667
+ gd.block_close()
2668
+ gd.block_close()
2669
+ gd.blank()
2670
+ gd.comment('Destroy if off-screen (far left)')
2671
+ gd.if_block('global_position.x < -200')
2672
+ gd.line('queue_free()')
2673
+ gd.block_close()
2674
+ gd.func_close()
2675
+ gd.blank()
2676
+
2677
+ # stomp handler
2678
+ if has_stomp:
2679
+ kill_score = defeat.on_kill.score if defeat.on_kill else 0
2680
+ gd.func('stomp')
2681
+ gd.line('_health -= 1')
2682
+ gd.if_block('_health <= 0')
2683
+ if kill_score:
2684
+ gd.line(f'QuantumEventBus.emit_event("enemy-killed", {{"score": {kill_score}, "other": self}})')
2685
+ else:
2686
+ gd.line('QuantumEventBus.emit_event("enemy-killed", {"other": self})')
2687
+ gd.line('queue_free()')
2688
+ gd.block_close()
2689
+ gd.func_close()
2690
+ gd.blank()
2691
+
2692
+ # die()
2693
+ gd.func('die')
2694
+ gd.line('QuantumEventBus.emit_event("enemy-killed", {"other": self})')
2695
+ gd.line('queue_free()')
2696
+ gd.func_close()
2697
+
2698
+ (scripts_dir / f'prefab_{name}.gd').write_text(gd.build(), encoding='utf-8')
2699
+
2700
+ def _write_emerge_enemy_script(self, scripts_dir: Path, name: str,
2701
+ prefab: PrefabNode, enemy: EnemyNode,
2702
+ anims: list = None):
2703
+ """Generate GDScript for emerge AI enemy (Piranha Plant - timer-based emerge/retract)."""
2704
+ gd = GdScriptBuilder()
2705
+ gd.extends('Node2D')
2706
+ gd.blank()
2707
+
2708
+ ai = enemy.ai
2709
+ emerge_speed = ai.speed if ai else 1.5
2710
+
2711
+ gd.export_var('emerge_height', 'float', '24.0')
2712
+ gd.export_var('wait_time', 'float', '2.0')
2713
+ gd.export_var('emerge_speed', 'float', f'{emerge_speed:.1f}')
2714
+ gd.export_var('safe_distance', 'float', '32.0')
2715
+ gd.blank()
2716
+
2717
+ gd.var('_emerged', 'false', 'bool')
2718
+ gd.var('_emerging', 'false', 'bool')
2719
+ gd.var('_retracting', 'false', 'bool')
2720
+ gd.var('_timer', '0.0', 'float')
2721
+ gd.var('_base_y', '0.0', 'float')
2722
+ gd.var('_current_offset', '0.0', 'float')
2723
+ gd.var('_player_near', 'false', 'bool')
2724
+ gd.blank()
2725
+
2726
+ # Animation variables
2727
+ anim_frames = [0, 1]
2728
+ anim_speed = 0.2
2729
+ if anims:
2730
+ for anim in anims:
2731
+ parsed = self._parse_frame_range(getattr(anim, 'frames', '0-1'))
2732
+ if parsed:
2733
+ anim_frames = parsed
2734
+ anim_speed = float(getattr(anim, 'speed', 0.2) or 0.2)
2735
+ break
2736
+ gd.var('_anim_timer', '0.0', 'float')
2737
+ gd.var('_anim_frames', str(anim_frames), 'Array')
2738
+ gd.var('_anim_idx', '0', 'int')
2739
+ gd.var('_anim_speed', f'{anim_speed:.2f}', 'float')
2740
+ gd.blank()
2741
+
2742
+ # _ready
2743
+ gd.func('_ready')
2744
+ gd.assign('_base_y', 'position.y')
2745
+ gd.func_close()
2746
+ gd.blank()
2747
+
2748
+ # _process: emerge/retract cycle
2749
+ gd.func('_process', 'delta: float')
2750
+ gd.comment('Check player proximity (safe zone)')
2751
+ gd.line('_check_player_proximity()')
2752
+ gd.blank()
2753
+
2754
+ gd.comment('Timer-based emerge/retract cycle')
2755
+ gd.line('_timer += delta')
2756
+ gd.blank()
2757
+
2758
+ gd.if_block('not _emerged and not _emerging')
2759
+ gd.comment('Waiting to emerge')
2760
+ gd.if_block('_timer >= wait_time and not _player_near')
2761
+ gd.assign('_emerging', 'true')
2762
+ gd.assign('_timer', '0.0')
2763
+ gd.block_close()
2764
+ gd.block_close()
2765
+ gd.blank()
2766
+
2767
+ gd.if_block('_emerging')
2768
+ gd.line('_current_offset = min(_current_offset + emerge_speed * delta * 60.0, emerge_height)')
2769
+ gd.assign('position.y', '_base_y - _current_offset')
2770
+ gd.if_block('_current_offset >= emerge_height')
2771
+ gd.assign('_emerged', 'true')
2772
+ gd.assign('_emerging', 'false')
2773
+ gd.assign('_timer', '0.0')
2774
+ gd.block_close()
2775
+ gd.block_close()
2776
+ gd.blank()
2777
+
2778
+ gd.if_block('_emerged and not _retracting')
2779
+ gd.if_block('_timer >= wait_time')
2780
+ gd.assign('_retracting', 'true')
2781
+ gd.assign('_timer', '0.0')
2782
+ gd.block_close()
2783
+ gd.block_close()
2784
+ gd.blank()
2785
+
2786
+ gd.if_block('_retracting')
2787
+ gd.line('_current_offset = max(_current_offset - emerge_speed * delta * 60.0, 0.0)')
2788
+ gd.assign('position.y', '_base_y - _current_offset')
2789
+ gd.if_block('_current_offset <= 0.0')
2790
+ gd.assign('_emerged', 'false')
2791
+ gd.assign('_retracting', 'false')
2792
+ gd.assign('_timer', '0.0')
2793
+ gd.block_close()
2794
+ gd.block_close()
2795
+ gd.blank()
2796
+
2797
+ gd.comment('Animate sprite')
2798
+ gd.line('_anim_timer += delta')
2799
+ gd.if_block('_anim_timer >= _anim_speed')
2800
+ gd.assign('_anim_timer', '0.0')
2801
+ gd.line('_anim_idx = (_anim_idx + 1) % _anim_frames.size()')
2802
+ gd.if_block('has_node("Sprite2D")')
2803
+ gd.line('$Sprite2D.frame = _anim_frames[_anim_idx]')
2804
+ gd.block_close()
2805
+ gd.block_close()
2806
+ gd.func_close()
2807
+ gd.blank()
2808
+
2809
+ # _check_player_proximity
2810
+ gd.func('_check_player_proximity')
2811
+ gd.assign('_player_near', 'false')
2812
+ gd.line('var players = get_tree().get_nodes_in_group("player")')
2813
+ gd.if_block('players.size() > 0')
2814
+ gd.line('var dist = abs(players[0].global_position.x - global_position.x)')
2815
+ gd.assign('_player_near', 'dist < safe_distance')
2816
+ gd.block_close()
2817
+ gd.func_close()
2818
+ gd.blank()
2819
+
2820
+ # die()
2821
+ gd.func('die')
2822
+ gd.line('QuantumEventBus.emit_event("enemy-killed", {"other": self})')
2823
+ gd.line('queue_free()')
2824
+ gd.func_close()
2825
+
2826
+ (scripts_dir / f'prefab_{name}.gd').write_text(gd.build(), encoding='utf-8')
2827
+
2828
+ def _write_block_script(self, scripts_dir: Path, name: str,
2829
+ prefab: PrefabNode, anims: list = None):
2830
+ """Generate GDScript for a question block (content + hits countdown)."""
2831
+ gd = GdScriptBuilder()
2832
+ gd.extends('StaticBody2D')
2833
+ gd.blank()
2834
+
2835
+ hits = prefab.hits if prefab.hits is not None else 1
2836
+ content = prefab.content or 'coin'
2837
+
2838
+ gd.var('_hits_remaining', str(hits), 'int')
2839
+ gd.var('_content', f'"{content}"', 'String')
2840
+ gd.var('_is_used', 'false', 'bool')
2841
+ gd.blank()
2842
+
2843
+ # Animation variables
2844
+ anim_frames = [0, 1, 2, 3]
2845
+ anim_speed = 0.15
2846
+ if anims:
2847
+ for anim in anims:
2848
+ parsed = self._parse_frame_range(getattr(anim, 'frames', '0-3'))
2849
+ if parsed:
2850
+ anim_frames = parsed
2851
+ anim_speed = float(getattr(anim, 'speed', 0.15) or 0.15)
2852
+ break
2853
+ gd.var('_anim_timer', '0.0', 'float')
2854
+ gd.var('_anim_frames', str(anim_frames), 'Array')
2855
+ gd.var('_anim_idx', '0', 'int')
2856
+ gd.var('_anim_speed', f'{anim_speed:.2f}', 'float')
2857
+ gd.blank()
2858
+
2859
+ # _process: animate while not used
2860
+ gd.func('_process', 'delta: float')
2861
+ gd.if_block('_is_used')
2862
+ gd.line('return')
2863
+ gd.block_close()
2864
+ gd.line('_anim_timer += delta')
2865
+ gd.if_block('_anim_timer >= _anim_speed')
2866
+ gd.assign('_anim_timer', '0.0')
2867
+ gd.line('_anim_idx = (_anim_idx + 1) % _anim_frames.size()')
2868
+ gd.if_block('has_node("Sprite2D")')
2869
+ gd.line('$Sprite2D.frame = _anim_frames[_anim_idx]')
2870
+ gd.block_close()
2871
+ gd.block_close()
2872
+ gd.func_close()
2873
+ gd.blank()
2874
+
2875
+ # hit(): countdown hits, switch to used
2876
+ gd.func('hit')
2877
+ gd.if_block('_is_used')
2878
+ gd.line('return')
2879
+ gd.block_close()
2880
+ gd.line('_hits_remaining -= 1')
2881
+ gd.line(f'QuantumEventBus.emit_event("block-hit", {{"content": _content, "other": self}})')
2882
+ gd.if_block('_hits_remaining <= 0')
2883
+ gd.assign('_is_used', 'true')
2884
+ gd.if_block('has_node("Sprite2D")')
2885
+ gd.line('$Sprite2D.frame = _anim_frames[-1]')
2886
+ gd.block_close()
2887
+ gd.block_close()
2888
+ gd.func_close()
2889
+
2890
+ (scripts_dir / f'prefab_{name}.gd').write_text(gd.build(), encoding='utf-8')
2891
+
2892
+ def _write_breakable_block_script(self, scripts_dir: Path, name: str,
2893
+ prefab: PrefabNode, anims: list = None):
2894
+ """Generate GDScript for a breakable block (tween rotation, toggle collision)."""
2895
+ gd = GdScriptBuilder()
2896
+ gd.extends('StaticBody2D')
2897
+ gd.blank()
2898
+
2899
+ gd.var('_is_breaking', 'false', 'bool')
2900
+ gd.blank()
2901
+
2902
+ # hit(): tween rotation + toggle collision
2903
+ gd.func('hit')
2904
+ gd.if_block('_is_breaking')
2905
+ gd.line('return')
2906
+ gd.block_close()
2907
+ gd.assign('_is_breaking', 'true')
2908
+ gd.line('var tween = create_tween()')
2909
+ gd.line('tween.tween_property(self, "rotation_degrees", 360.0, 0.25)')
2910
+ gd.line('tween.set_loops(4)')
2911
+ gd.line('tween.tween_callback(_on_break_complete)')
2912
+ gd.func_close()
2913
+ gd.blank()
2914
+
2915
+ # _on_break_complete(): disable collision temporarily
2916
+ gd.func('_on_break_complete')
2917
+ gd.if_block('has_node("CollisionShape2D")')
2918
+ gd.line('$CollisionShape2D.disabled = true')
2919
+ gd.block_close()
2920
+ gd.line('QuantumEventBus.emit_event("block-broken", {"other": self})')
2921
+ gd.line('var timer = get_tree().create_timer(2.0)')
2922
+ gd.line('timer.timeout.connect(_restore)')
2923
+ gd.func_close()
2924
+ gd.blank()
2925
+
2926
+ # _restore(): re-enable collision
2927
+ gd.func('_restore')
2928
+ gd.assign('rotation_degrees', '0.0')
2929
+ gd.assign('_is_breaking', 'false')
2930
+ gd.if_block('has_node("CollisionShape2D")')
2931
+ gd.line('$CollisionShape2D.disabled = false')
2932
+ gd.block_close()
2933
+ gd.func_close()
2934
+
2935
+ (scripts_dir / f'prefab_{name}.gd').write_text(gd.build(), encoding='utf-8')
2936
+
2937
+ def _write_flying_block_script(self, scripts_dir: Path, name: str,
2938
+ prefab: PrefabNode, anims: list = None):
2939
+ """Generate GDScript for a flying block (oscillating movement + hit drops + wings)."""
2940
+ gd = GdScriptBuilder()
2941
+ gd.extends('StaticBody2D')
2942
+ gd.blank()
2943
+
2944
+ hits = prefab.hits if prefab.hits is not None else 1
2945
+ content = prefab.content or 'coin'
2946
+
2947
+ gd.var('_hits_remaining', str(hits), 'int')
2948
+ gd.var('_content', f'"{content}"', 'String')
2949
+ gd.var('_is_used', 'false', 'bool')
2950
+ gd.var('_is_flying', 'true', 'bool')
2951
+ gd.var('_fly_time', '0.0', 'float')
2952
+ gd.export_var('fly_speed', 'float', '30.0')
2953
+ gd.export_var('fly_amplitude', 'float', '40.0')
2954
+ gd.var('_base_x', '0.0', 'float')
2955
+ gd.blank()
2956
+
2957
+ # Animation variables
2958
+ anim_frames = [0, 1, 2, 3]
2959
+ anim_speed = 0.15
2960
+ if anims:
2961
+ for anim in anims:
2962
+ parsed = self._parse_frame_range(getattr(anim, 'frames', '0-3'))
2963
+ if parsed:
2964
+ anim_frames = parsed
2965
+ anim_speed = float(getattr(anim, 'speed', 0.15) or 0.15)
2966
+ break
2967
+ gd.var('_anim_timer', '0.0', 'float')
2968
+ gd.var('_anim_frames', str(anim_frames), 'Array')
2969
+ gd.var('_anim_idx', '0', 'int')
2970
+ gd.var('_anim_speed', f'{anim_speed:.2f}', 'float')
2971
+ gd.blank()
2972
+
2973
+ # _ready
2974
+ gd.func('_ready')
2975
+ gd.assign('_base_x', 'position.x')
2976
+ gd.func_close()
2977
+ gd.blank()
2978
+
2979
+ # _process: oscillate while flying, animate
2980
+ gd.func('_process', 'delta: float')
2981
+ gd.if_block('_is_flying')
2982
+ gd.line('_fly_time += delta')
2983
+ gd.assign('position.x', '_base_x + sin(_fly_time * 2.0) * fly_amplitude')
2984
+ gd.block_close()
2985
+ gd.blank()
2986
+ gd.if_block('_is_used')
2987
+ gd.line('return')
2988
+ gd.block_close()
2989
+ gd.line('_anim_timer += delta')
2990
+ gd.if_block('_anim_timer >= _anim_speed')
2991
+ gd.assign('_anim_timer', '0.0')
2992
+ gd.line('_anim_idx = (_anim_idx + 1) % _anim_frames.size()')
2993
+ gd.if_block('has_node("Sprite2D")')
2994
+ gd.line('$Sprite2D.frame = _anim_frames[_anim_idx]')
2995
+ gd.block_close()
2996
+ gd.block_close()
2997
+ gd.func_close()
2998
+ gd.blank()
2999
+
3000
+ # hit(): stop flying, remove wings, release content
3001
+ gd.func('hit')
3002
+ gd.if_block('_is_used')
3003
+ gd.line('return')
3004
+ gd.block_close()
3005
+ gd.line('_hits_remaining -= 1')
3006
+ gd.blank()
3007
+ gd.comment('Stop flying and remove wings')
3008
+ gd.assign('_is_flying', 'false')
3009
+ gd.if_block('has_node("Wings")')
3010
+ gd.line('$Wings.queue_free()')
3011
+ gd.block_close()
3012
+ gd.blank()
3013
+ gd.line(f'QuantumEventBus.emit_event("block-hit", {{"content": _content, "other": self}})')
3014
+ gd.if_block('_hits_remaining <= 0')
3015
+ gd.assign('_is_used', 'true')
3016
+ gd.if_block('has_node("Sprite2D")')
3017
+ gd.line('$Sprite2D.frame = _anim_frames[-1]')
3018
+ gd.block_close()
3019
+ gd.block_close()
3020
+ gd.func_close()
3021
+
3022
+ (scripts_dir / f'prefab_{name}.gd').write_text(gd.build(), encoding='utf-8')
3023
+
3024
+ def _write_collectible_script(self, scripts_dir: Path, name: str,
3025
+ prefab: PrefabNode, prefab_tag: str = None,
3026
+ anims: list = None):
3027
+ """Generate GDScript for a collectible item (Area2D body_entered, emit, queue_free)."""
3028
+ gd = GdScriptBuilder()
3029
+ gd.extends('Area2D')
3030
+ gd.blank()
3031
+
3032
+ gd.var('_collected', 'false', 'bool')
3033
+
3034
+ # Yoshi coin shimmer effect (palette shimmer instead of fake 3D spin)
3035
+ is_yoshi_coin = (prefab_tag == 'yoshi_coin')
3036
+ if is_yoshi_coin:
3037
+ gd.var('_shimmer_time', 'randf() * TAU', 'float')
3038
+ gd.blank()
3039
+
3040
+ # Animation variables
3041
+ has_anim = anims and len(anims) > 0
3042
+ if has_anim:
3043
+ anim_frames = [0, 1, 2, 3]
3044
+ anim_speed = 0.15
3045
+ for anim in anims:
3046
+ parsed = self._parse_frame_range(getattr(anim, 'frames', '0-3'))
3047
+ if parsed:
3048
+ anim_frames = parsed
3049
+ anim_speed = float(getattr(anim, 'speed', 0.15) or 0.15)
3050
+ break
3051
+ gd.var('_anim_timer', '0.0', 'float')
3052
+ gd.var('_anim_frames', str(anim_frames), 'Array')
3053
+ gd.var('_anim_idx', '0', 'int')
3054
+ gd.var('_anim_speed', f'{anim_speed:.2f}', 'float')
3055
+ gd.blank()
3056
+
3057
+ # _ready: connect body_entered signal
3058
+ gd.func('_ready')
3059
+ gd.line('body_entered.connect(_on_body_entered)')
3060
+ gd.func_close()
3061
+ gd.blank()
3062
+
3063
+ # _process: animation + yoshi coin spin
3064
+ if has_anim or is_yoshi_coin:
3065
+ gd.func('_process', 'delta: float')
3066
+ if is_yoshi_coin:
3067
+ gd.line('_shimmer_time += delta * 3.0')
3068
+ gd.line('var shimmer = 0.15 * sin(_shimmer_time)')
3069
+ gd.if_block('has_node("Sprite2D")')
3070
+ gd.line('$Sprite2D.modulate = Color(1.0 + shimmer, 1.0 + shimmer * 0.5, 0.7, 1.0)')
3071
+ gd.block_close()
3072
+ if has_anim:
3073
+ gd.line('_anim_timer += delta')
3074
+ gd.if_block('_anim_timer >= _anim_speed')
3075
+ gd.assign('_anim_timer', '0.0')
3076
+ gd.line('_anim_idx = (_anim_idx + 1) % _anim_frames.size()')
3077
+ gd.if_block('has_node("Sprite2D")')
3078
+ gd.line('$Sprite2D.frame = _anim_frames[_anim_idx]')
3079
+ gd.block_close()
3080
+ gd.block_close()
3081
+ gd.func_close()
3082
+ gd.blank()
3083
+
3084
+ # Determine event name from reward or tag
3085
+ event_name = 'item-collected'
3086
+ if prefab.reward:
3087
+ parts = prefab.reward.split(':')
3088
+ if len(parts) == 2:
3089
+ event_name = f'{prefab_tag or name}-collected'
3090
+
3091
+ # _on_body_entered: collect item
3092
+ gd.func('_on_body_entered', 'body: Node')
3093
+ gd.if_block('_collected')
3094
+ gd.line('return')
3095
+ gd.block_close()
3096
+ gd.if_block('body is CharacterBody2D')
3097
+ gd.assign('_collected', 'true')
3098
+ # Emit event with score if reward specified
3099
+ if prefab.reward:
3100
+ parts = prefab.reward.split(':')
3101
+ if len(parts) == 2:
3102
+ gd.line(f'QuantumEventBus.emit_event("{event_name}", {{"score": {parts[1]}, "other": self}})')
3103
+ else:
3104
+ gd.line(f'QuantumEventBus.emit_event("{event_name}", {{"other": self}})')
3105
+ else:
3106
+ gd.line(f'QuantumEventBus.emit_event("{event_name}", {{"other": self}})')
3107
+ gd.line('queue_free()')
3108
+ gd.block_close()
3109
+ gd.func_close()
3110
+
3111
+ (scripts_dir / f'prefab_{name}.gd').write_text(gd.build(), encoding='utf-8')
3112
+
3113
+ def _write_checkpoint_script(self, scripts_dir: Path, name: str,
3114
+ prefab: PrefabNode, anims: list = None):
3115
+ """Generate GDScript for a checkpoint (one-time activation, bar disappear, SFX)."""
3116
+ gd = GdScriptBuilder()
3117
+ gd.extends('Area2D')
3118
+ gd.blank()
3119
+
3120
+ gd.line('signal checkpoint_activated(pos: Vector2)')
3121
+ gd.blank()
3122
+ gd.var('_activated', 'false', 'bool')
3123
+ gd.blank()
3124
+
3125
+ # _ready: connect body_entered, ensure collision_mask targets player
3126
+ gd.func('_ready')
3127
+ gd.line('body_entered.connect(_on_body_entered)')
3128
+ gd.comment('Ensure collision_mask detects player layer (2)')
3129
+ gd.assign('collision_layer', '0')
3130
+ gd.assign('collision_mask', '2')
3131
+ gd.func_close()
3132
+ gd.blank()
3133
+
3134
+ # _on_body_entered: activate checkpoint with SMW-style feedback
3135
+ gd.func('_on_body_entered', 'body: Node')
3136
+ gd.if_block('_activated')
3137
+ gd.line('return')
3138
+ gd.block_close()
3139
+ gd.if_block('body is CharacterBody2D')
3140
+ gd.assign('_activated', 'true')
3141
+ gd.line('checkpoint_activated.emit(global_position)')
3142
+ gd.line('QuantumEventBus.emit_event("checkpoint-activated", {"position": global_position, "other": self})')
3143
+ gd.blank()
3144
+ gd.comment('Visual feedback: flash green then fade to semi-transparent')
3145
+ gd.line('var tween = create_tween()')
3146
+ gd.line('tween.tween_property(self, "modulate", Color(0.0, 1.0, 0.0, 1.0), 0.1)')
3147
+ gd.line('tween.tween_property(self, "modulate", Color(0.5, 1.0, 0.5, 0.6), 0.3)')
3148
+ gd.blank()
3149
+ gd.comment('Hide bar/cordao child if present')
3150
+ gd.if_block('has_node("Bar")')
3151
+ gd.line('var bar_tween = create_tween()')
3152
+ gd.line('bar_tween.tween_property($Bar, "scale", Vector2(0, 1), 0.2)')
3153
+ gd.line('bar_tween.tween_callback($Bar.queue_free)')
3154
+ gd.block_close()
3155
+ gd.block_close()
3156
+ gd.func_close()
3157
+
3158
+ (scripts_dir / f'prefab_{name}.gd').write_text(gd.build(), encoding='utf-8')
3159
+
3160
+ def _write_player_controller(self, scripts_dir: Path):
3161
+ """Write player_controller.gd if any sprite has controls."""
3162
+ player = None
3163
+ for s in self._sprites:
3164
+ if s.get('controls'):
3165
+ player = s
3166
+ break
3167
+
3168
+ if not player:
3169
+ return
3170
+
3171
+ # Use sprite-level physics attrs if specified, fallback to SMW constants
3172
+ speed = float(player.get('speed') or 0) or SMW_WALK_SPEED
3173
+ # Convert abstract speed to Godot px/s: if small value (< 10), scale by 60
3174
+ if speed < 10:
3175
+ speed = speed * 60.0
3176
+ jump_vel = SMW_JUMP_VELOCITY
3177
+ jump_force = player.get('jump_force')
3178
+ if jump_force and float(jump_force) > 0:
3179
+ # Convert abstract jump force to Godot velocity
3180
+ jf = float(jump_force)
3181
+ if jf < 50:
3182
+ jump_vel = -jf * 53.0 # Scale: 5.3 -> ~-280
3183
+ else:
3184
+ jump_vel = -jf
3185
+ gravity_up = float(player.get('gravity_up') or 0) or SMW_GRAVITY_RISING
3186
+ gravity_down = float(player.get('gravity_down') or 0) or SMW_GRAVITY_FALLING
3187
+ jump_hold_boost = float(player.get('jump_hold_boost') or 0) or SMW_JUMP_HOLD_BOOST
3188
+ max_fall = float(player.get('max_fall_speed') or 0) or SMW_MAX_FALL
3189
+ coyote_frames = int(player.get('coyote_frames') or 6)
3190
+ collision_layer = player.get('collision_layer')
3191
+ collision_mask = player.get('collision_mask')
3192
+ floor_max_angle = player.get('floor_max_angle')
3193
+
3194
+ # Extract animation data from sprite
3195
+ idle_frame = 0
3196
+ walk_frames = [1, 2, 3]
3197
+ jump_frame = 4
3198
+ walk_anim_speed = 0.12
3199
+
3200
+ for anim in player.get('animations', []):
3201
+ anim_name = anim.get('name', '')
3202
+ frames_str = anim.get('frames', '')
3203
+ anim_speed = anim.get('speed', 0.12)
3204
+
3205
+ if anim_name == 'idle':
3206
+ idle_frame = self._parse_frame_range(frames_str)[0] if frames_str else 0
3207
+ elif anim_name == 'walk':
3208
+ walk_frames = self._parse_frame_range(frames_str) if frames_str else [1, 2, 3]
3209
+ walk_anim_speed = float(anim_speed) if anim_speed else 0.12
3210
+ elif anim_name == 'jump':
3211
+ jump_frame = self._parse_frame_range(frames_str)[0] if frames_str else 4
3212
+
3213
+ content = PLAYER_CONTROLLER_GD.format(
3214
+ speed=f'{speed:.1f}',
3215
+ jump_velocity=f'{jump_vel:.1f}',
3216
+ gravity_up=f'{gravity_up:.1f}',
3217
+ gravity_down=f'{gravity_down:.1f}',
3218
+ jump_hold_boost=f'{jump_hold_boost:.1f}',
3219
+ coyote_frames=coyote_frames,
3220
+ max_fall_speed=f'{max_fall:.1f}',
3221
+ idle_frame=idle_frame,
3222
+ walk_frames=str(walk_frames),
3223
+ walk_anim_speed=f'{walk_anim_speed:.2f}',
3224
+ jump_frame=jump_frame,
3225
+ collision_layer=self._resolve_collision_value(collision_layer),
3226
+ collision_mask=self._resolve_collision_value(collision_mask),
3227
+ floor_max_angle=f'{float(floor_max_angle) if floor_max_angle is not None else 45.0:.1f}',
3228
+ )
3229
+
3230
+ # Add spin jump support if enabled
3231
+ has_spin_jump = player.get('spin_jump', False)
3232
+ if has_spin_jump:
3233
+ content += SPIN_JUMP_SECTION
3234
+
3235
+ (scripts_dir / 'player_controller.gd').write_text(content, encoding='utf-8')
3236
+
3237
+ @staticmethod
3238
+ def _parse_frame_range(frames_str: str) -> list:
3239
+ """Parse frame range string like '0', '1-3', '0,1,2,3' into list of ints."""
3240
+ frames = []
3241
+ for part in str(frames_str).split(','):
3242
+ part = part.strip()
3243
+ if '-' in part:
3244
+ start, end = part.split('-', 1)
3245
+ frames.extend(range(int(start), int(end) + 1))
3246
+ else:
3247
+ try:
3248
+ frames.append(int(part))
3249
+ except ValueError:
3250
+ pass
3251
+ return frames or [0]
3252
+
3253
+ def _write_camera_script(self, scripts_dir: Path):
3254
+ """Write camera_follow.gd if camera exists."""
3255
+ if not self._camera:
3256
+ return
3257
+
3258
+ cam = self._camera
3259
+ lerp_speed = cam.get('lerp', 0.1) * 60 # Convert to Godot smoothing speed
3260
+ use_bounds = cam.get('bounds') == 'scene'
3261
+
3262
+ content = CAMERA_FOLLOW_GD.format(
3263
+ lerp_speed=f'{lerp_speed:.1f}',
3264
+ use_bounds=gd_bool(use_bounds),
3265
+ bound_right=f'{float(self._scene_width):.1f}',
3266
+ bound_bottom=f'{float(self._scene_height):.1f}',
3267
+ )
3268
+ (scripts_dir / 'camera_follow.gd').write_text(content, encoding='utf-8')
3269
+
3270
+ def _write_ai_scripts(self, scripts_dir: Path):
3271
+ """Write patrol_ai.gd if any sprite needs it."""
3272
+ needs_patrol = False
3273
+ for s in self._sprites:
3274
+ if s.get('body') == 'dynamic' and not s.get('controls'):
3275
+ if s.get('tag') in ('enemy',) or s.get('behaviors'):
3276
+ needs_patrol = True
3277
+ break
3278
+
3279
+ if not needs_patrol:
3280
+ return
3281
+
3282
+ gravity_y = 980.0
3283
+ if self._physics:
3284
+ gravity_y = q2g_gravity(self._physics['gravity_y'])
3285
+
3286
+ content = PATROL_AI_GD.format(
3287
+ speed=f'{q2g_speed(0.5):.1f}',
3288
+ turn_on_edge=gd_bool(True),
3289
+ gravity=f'{gravity_y:.1f}',
3290
+ initial_direction='-1.0',
3291
+ )
3292
+ (scripts_dir / 'patrol_ai.gd').write_text(content, encoding='utf-8')
3293
+
3294
+ def _write_hud_script(self, scripts_dir: Path):
3295
+ """Write hud_manager.gd if HUD exists."""
3296
+ if not self._huds:
3297
+ return
3298
+
3299
+ if self._has_tile_hud():
3300
+ self._write_tile_hud_script(scripts_dir)
3301
+ return
3302
+
3303
+ # Legacy HTML-based HUD
3304
+ label_names = []
3305
+ label_index = 0
3306
+ for hud in self._huds:
3307
+ for child in hud.get('children', []):
3308
+ if child.get('type') == 'html':
3309
+ attrs = child.get('attrs', {})
3310
+ name = attrs.get('id', f'label_{label_index}')
3311
+ label_names.append(name)
3312
+ label_index += 1
3313
+
3314
+ label_vars = '\n'.join(
3315
+ f'@onready var {name}_label: Label = ${name}'
3316
+ for name in label_names
3317
+ )
3318
+ label_ready = '\n'.join(
3319
+ f'\tpass # Labels auto-connected via @onready'
3320
+ for _ in label_names
3321
+ ) or '\tpass'
3322
+
3323
+ content = HUD_MANAGER_GD.format(
3324
+ label_vars=label_vars,
3325
+ label_ready=label_ready,
3326
+ )
3327
+ (scripts_dir / 'hud_manager.gd').write_text(content, encoding='utf-8')
3328
+
3329
+ def _write_tile_hud_script(self, scripts_dir: Path):
3330
+ """Generate tile-based hud_manager.gd using GdScriptBuilder."""
3331
+ gd = GdScriptBuilder()
3332
+ gd.extends('CanvasLayer')
3333
+ gd.comment('HUD Manager - Tile-based SNES-style overlay')
3334
+ gd.comment('Generated by Quantum Framework')
3335
+ gd.blank()
3336
+
3337
+ # Collect all tile-based HUD data
3338
+ all_tiles = []
3339
+ all_counters = []
3340
+ all_collections = []
3341
+ all_slots = []
3342
+ all_behaviors = []
3343
+ bg_color = None
3344
+ bg_height = 26
3345
+
3346
+ for hud in self._huds:
3347
+ if hud.get('background'):
3348
+ bg_color = hud['background']
3349
+ if hud.get('background_height'):
3350
+ bg_height = hud['background_height']
3351
+ all_tiles.extend(hud.get('tiles', []))
3352
+ all_counters.extend(hud.get('counters', []))
3353
+ all_collections.extend(hud.get('collections', []))
3354
+ all_slots.extend(hud.get('slots', []))
3355
+ all_behaviors.extend(hud.get('behaviors', []))
3356
+
3357
+ # Collect all unique textures
3358
+ all_sprites = set()
3359
+ for t in all_tiles:
3360
+ all_sprites.add(t['sprite'])
3361
+ for c in all_counters:
3362
+ all_sprites.add(c['strip'])
3363
+ if c.get('icon'):
3364
+ all_sprites.add(c['icon'])
3365
+ if c.get('symbol'):
3366
+ all_sprites.add(c['symbol'])
3367
+ for col in all_collections:
3368
+ all_sprites.add(col['sprite'])
3369
+ for slot in all_slots:
3370
+ for opt in slot.get('options', []):
3371
+ all_sprites.add(opt['sprite'])
3372
+
3373
+ # Texture variables
3374
+ for sprite_path in sorted(all_sprites):
3375
+ var_name = self._sprite_var_name(sprite_path)
3376
+ gd.raw(f'var {var_name}: Texture2D')
3377
+ gd.blank()
3378
+
3379
+ # State variables for counters
3380
+ for counter in all_counters:
3381
+ bind = counter['bind']
3382
+ gd.raw(f'var {bind}: int = 0')
3383
+ for col in all_collections:
3384
+ bind = col['bind']
3385
+ gd.raw(f'var {bind}: int = 0')
3386
+ for slot in all_slots:
3387
+ bind = slot['bind']
3388
+ gd.raw(f'var {bind}: String = ""')
3389
+ gd.blank()
3390
+
3391
+ # Countdown variables
3392
+ has_countdown = any(c.get('countdown') for c in all_counters)
3393
+ has_hurry = any(c.get('hurry_at') is not None for c in all_counters)
3394
+ if has_countdown:
3395
+ gd.raw('var _countdown_timer: float = 0.0')
3396
+ gd.raw('var _countdown_active: bool = true')
3397
+ if has_hurry:
3398
+ gd.raw('var _hurry_mode: bool = false')
3399
+ gd.raw('var _hurry_flash_timer: float = 0.0')
3400
+ gd.raw('var _bonus_countdown_active: bool = false')
3401
+ gd.raw('var _bonus_countdown_timer: float = 0.0')
3402
+ gd.raw('var _course_clear: bool = false')
3403
+ gd.raw('var _canvas: Control')
3404
+ # Tint color variables
3405
+ tint_colors = set()
3406
+ for c in all_counters:
3407
+ if c.get('tint'):
3408
+ tint_colors.add(c['tint'])
3409
+ for color_name in sorted(tint_colors):
3410
+ gd.raw(f'var _tint_{color_name}: Color = {self._flash_color(color_name)}')
3411
+ gd.blank()
3412
+
3413
+ # _ready()
3414
+ gd.func('_ready')
3415
+ for sprite_path in sorted(all_sprites):
3416
+ var_name = self._sprite_var_name(sprite_path)
3417
+ asset = self._hud_res_path(sprite_path)
3418
+ gd.line(f'{var_name} = load("{asset}")')
3419
+ gd.line('_canvas = Control.new()')
3420
+ gd.line('_canvas.set_anchors_preset(Control.PRESET_FULL_RECT)')
3421
+ gd.line('_canvas.connect("draw", _on_draw)')
3422
+ gd.line('add_child(_canvas)')
3423
+ gd.func_close()
3424
+ gd.blank()
3425
+
3426
+ # _process(delta)
3427
+ gd.func('_process', 'delta: float')
3428
+ if has_countdown:
3429
+ gd.if_block('_countdown_active')
3430
+ gd.line('_countdown_timer += delta')
3431
+ gd.if_block('_countdown_timer >= 1.0')
3432
+ gd.line('_countdown_timer -= 1.0')
3433
+ # Find countdown counters
3434
+ for counter in all_counters:
3435
+ if counter.get('countdown'):
3436
+ bind = counter['bind']
3437
+ gd.line(f'{bind} = max(0, {bind} - 1)')
3438
+ if counter.get('hurry_at') is not None:
3439
+ hurry_val = counter['hurry_at']
3440
+ gd.if_block(f'{bind} == {hurry_val}')
3441
+ gd.line('_hurry_mode = true')
3442
+ gd.line('QuantumEventBus.emit_event("time-hurry", {})')
3443
+ gd.block_close()
3444
+ gd.block_close() # timer >= 1.0
3445
+ gd.block_close() # countdown_active
3446
+ if has_hurry:
3447
+ gd.if_block('_hurry_mode')
3448
+ gd.line('_hurry_flash_timer += delta')
3449
+ gd.block_close()
3450
+ # Bonus countdown
3451
+ gd.if_block('_bonus_countdown_active')
3452
+ gd.line('_bonus_countdown_timer += delta')
3453
+ gd.if_block('_bonus_countdown_timer >= 0.02')
3454
+ gd.line('_bonus_countdown_timer -= 0.02')
3455
+ for counter in all_counters:
3456
+ if counter.get('countdown'):
3457
+ bind = counter['bind']
3458
+ gd.line(f'if {bind} > 0:')
3459
+ gd.raw(f'\t\t\t{bind} -= 1')
3460
+ gd.block_close()
3461
+ gd.block_close()
3462
+ # Behavior checks
3463
+ for behavior in all_behaviors:
3464
+ target = behavior['target']
3465
+ event_type = behavior['event']
3466
+ value = behavior.get('value')
3467
+ if event_type == 'reach' and value is not None:
3468
+ for action in behavior.get('actions', []):
3469
+ if action['action_type'] == 'emit' and action.get('event'):
3470
+ if value == 0:
3471
+ gd.if_block(f'{target} <= 0')
3472
+ else:
3473
+ gd.if_block(f'{target} == {value}')
3474
+ gd.line(f'QuantumEventBus.emit_event("{action["event"]}", {{}})')
3475
+ gd.block_close()
3476
+ gd.line('_canvas.queue_redraw()')
3477
+ gd.func_close()
3478
+ gd.blank()
3479
+
3480
+ # _on_draw()
3481
+ gd.func('_on_draw')
3482
+ # Background bar
3483
+ if bg_color:
3484
+ godot_color = self._parse_rgba(bg_color)
3485
+ gd.line(f'_canvas.draw_rect(Rect2(0, 0, _canvas.size.x, {bg_height}), {godot_color})')
3486
+ gd.blank()
3487
+
3488
+ # Draw tiles
3489
+ for tile in all_tiles:
3490
+ var_name = self._sprite_var_name(tile['sprite'])
3491
+ x, y = tile['x'], tile['y']
3492
+ if tile.get('width') and tile.get('height'):
3493
+ gd.line(f'_canvas.draw_texture_rect({var_name}, Rect2({x}, {y}, {tile["width"]}, {tile["height"]}), false)')
3494
+ else:
3495
+ gd.line(f'_canvas.draw_texture({var_name}, Vector2({x}, {y}))')
3496
+ gd.blank()
3497
+
3498
+ # Draw counters
3499
+ for counter in all_counters:
3500
+ bind = counter['bind']
3501
+ x, y = counter['x'], counter['y']
3502
+ digits = counter['digits']
3503
+ dw = counter['digit_width']
3504
+ dh = counter['digit_height']
3505
+ align = counter.get('align', 'left')
3506
+ tint = counter.get('tint')
3507
+ right_edge = counter.get('right_edge')
3508
+ icon = counter.get('icon')
3509
+ symbol = counter.get('symbol')
3510
+ tint_expr = f'_tint_{tint}' if tint else None
3511
+
3512
+ if align == 'right' and right_edge is not None:
3513
+ # Right-aligned counter
3514
+ if icon and symbol:
3515
+ icon_var = self._sprite_var_name(icon)
3516
+ sym_var = self._sprite_var_name(symbol)
3517
+ gd.line(f'_draw_counter_right({bind}, {right_edge}, {y}, {icon_var}, {sym_var}, {dw}, {dh}, {tint_expr or "Color(1,1,1,1)"})')
3518
+ else:
3519
+ if counter.get('hurry_at') is not None and counter.get('flash'):
3520
+ flash_color = self._flash_color(counter['flash'])
3521
+ gd.if_block('_hurry_mode and int(_hurry_flash_timer * 4) % 2 == 0')
3522
+ gd.line(f'_draw_number_right({bind}, {right_edge}, {y}, {dw}, {dh}, {flash_color})')
3523
+ gd.else_block()
3524
+ gd.line(f'_draw_number_right({bind}, {right_edge}, {y}, {dw}, {dh}, {tint_expr or "Color(1,1,1,1)"})')
3525
+ gd.block_close()
3526
+ else:
3527
+ gd.line(f'_draw_number_right({bind}, {right_edge}, {y}, {dw}, {dh}, {tint_expr or "Color(1,1,1,1)"})')
3528
+ else:
3529
+ # Left-aligned counter
3530
+ if counter.get('hurry_at') is not None and counter.get('flash'):
3531
+ flash_color = self._flash_color(counter['flash'])
3532
+ gd.if_block('_hurry_mode and int(_hurry_flash_timer * 4) % 2 == 0')
3533
+ gd.line(f'_draw_number_tinted({bind}, {x}, {y}, {digits}, {dw}, {dh}, {flash_color})')
3534
+ gd.else_block()
3535
+ if tint_expr:
3536
+ gd.line(f'_draw_number_tinted({bind}, {x}, {y}, {digits}, {dw}, {dh}, {tint_expr})')
3537
+ else:
3538
+ gd.line(f'_draw_number({bind}, {x}, {y}, {digits}, {dw}, {dh})')
3539
+ gd.block_close()
3540
+ elif tint_expr:
3541
+ gd.line(f'_draw_number_tinted({bind}, {x}, {y}, {digits}, {dw}, {dh}, {tint_expr})')
3542
+ else:
3543
+ gd.line(f'_draw_number({bind}, {x}, {y}, {digits}, {dw}, {dh})')
3544
+ gd.blank()
3545
+
3546
+ # Draw collections
3547
+ for col in all_collections:
3548
+ bind = col['bind']
3549
+ var_name = self._sprite_var_name(col['sprite'])
3550
+ x, y = col['x'], col['y']
3551
+ spacing = col['spacing']
3552
+ max_count = col['max']
3553
+ gd.for_block('i', f'range({bind})')
3554
+ gd.line(f'_canvas.draw_texture({var_name}, Vector2({x} + i * {spacing}, {y}))')
3555
+ gd.block_close()
3556
+ gd.blank()
3557
+
3558
+ # Draw slots
3559
+ for slot in all_slots:
3560
+ bind = slot['bind']
3561
+ x, y = slot['x'], slot['y']
3562
+ for opt in slot.get('options', []):
3563
+ opt_var = self._sprite_var_name(opt['sprite'])
3564
+ gd.if_block(f'{bind} == "{opt["value"]}"')
3565
+ gd.line(f'_canvas.draw_texture({opt_var}, Vector2({x}, {y}))')
3566
+ gd.block_close()
3567
+
3568
+ # Course clear overlay
3569
+ gd.if_block('_course_clear')
3570
+ gd.line('_draw_course_clear()')
3571
+ gd.block_close()
3572
+ gd.func_close()
3573
+ gd.blank()
3574
+
3575
+ # _draw_number() helper
3576
+ # Find the first counter's strip for the texture reference
3577
+ strip_var = self._sprite_var_name(all_counters[0]['strip']) if all_counters else 'null'
3578
+ gd.func('_draw_number', 'value: int, x: int, y: int, digits: int, dw: int, dh: int')
3579
+ gd.line('var s = str(value)')
3580
+ gd.line('while s.length() < digits:')
3581
+ gd.raw('\t\ts = "0" + s')
3582
+ gd.line('for i in range(s.length()):')
3583
+ gd.raw(f'\t\tvar digit = s[i].to_int()')
3584
+ gd.raw(f'\t\tvar src_rect = Rect2(digit * dw, 0, dw, dh)')
3585
+ gd.raw(f'\t\tvar dst_rect = Rect2(x + i * dw, y, dw, dh)')
3586
+ gd.raw(f'\t\t_canvas.draw_texture_rect_region({strip_var}, dst_rect, src_rect)')
3587
+ gd.func_close()
3588
+ gd.blank()
3589
+
3590
+ # _draw_number_tinted() helper
3591
+ gd.func('_draw_number_tinted', 'value: int, x: int, y: int, digits: int, dw: int, dh: int, tint: Color')
3592
+ gd.line('var s = str(value)')
3593
+ gd.line('while s.length() < digits:')
3594
+ gd.raw('\t\ts = "0" + s')
3595
+ gd.line('for i in range(s.length()):')
3596
+ gd.raw(f'\t\tvar digit = s[i].to_int()')
3597
+ gd.raw(f'\t\tvar src_rect = Rect2(digit * dw, 0, dw, dh)')
3598
+ gd.raw(f'\t\tvar dst_rect = Rect2(x + i * dw, y, dw, dh)')
3599
+ gd.raw(f'\t\t_canvas.draw_texture_rect_region({strip_var}, dst_rect, src_rect, tint)')
3600
+ gd.func_close()
3601
+ gd.blank()
3602
+
3603
+ # _draw_number_right() - right-aligned, intelligent display (no zero-padding)
3604
+ has_right_counter = any(c.get('align') == 'right' for c in all_counters)
3605
+ if has_right_counter:
3606
+ gd.func('_draw_number_right', 'value: int, right_x: int, y: int, dw: int, dh: int, tint: Color')
3607
+ gd.line('var s = str(value)')
3608
+ gd.line('var start_x = right_x - s.length() * dw')
3609
+ gd.line('for i in range(s.length()):')
3610
+ gd.raw(f'\t\tvar digit = s[i].to_int()')
3611
+ gd.raw(f'\t\tvar src_rect = Rect2(digit * dw, 0, dw, dh)')
3612
+ gd.raw(f'\t\tvar dst_rect = Rect2(start_x + i * dw, y, dw, dh)')
3613
+ gd.raw(f'\t\t_canvas.draw_texture_rect_region({strip_var}, dst_rect, src_rect, tint)')
3614
+ gd.func_close()
3615
+ gd.blank()
3616
+
3617
+ # _draw_counter_right() - composite right-aligned (icon + symbol + digits)
3618
+ has_composite = any(c.get('icon') and c.get('align') == 'right' for c in all_counters)
3619
+ if has_composite:
3620
+ gd.func('_draw_counter_right', 'value: int, right_x: int, y: int, icon: Texture2D, sym: Texture2D, dw: int, dh: int, tint: Color')
3621
+ gd.line('var s = str(value)')
3622
+ gd.line('var digits_w = s.length() * dw')
3623
+ gd.line('var sym_x = right_x - digits_w - dw')
3624
+ gd.line('var icon_x = sym_x - dw')
3625
+ gd.line('_canvas.draw_texture(icon, Vector2(icon_x, y))')
3626
+ gd.line('_canvas.draw_texture(sym, Vector2(sym_x, y))')
3627
+ gd.line('var start_x = right_x - digits_w')
3628
+ gd.line('for i in range(s.length()):')
3629
+ gd.raw(f'\t\tvar digit = s[i].to_int()')
3630
+ gd.raw(f'\t\tvar src_rect = Rect2(digit * dw, 0, dw, dh)')
3631
+ gd.raw(f'\t\tvar dst_rect = Rect2(start_x + i * dw, y, dw, dh)')
3632
+ gd.raw(f'\t\t_canvas.draw_texture_rect_region({strip_var}, dst_rect, src_rect, tint)')
3633
+ gd.func_close()
3634
+ gd.blank()
3635
+
3636
+ # _draw_course_clear()
3637
+ gd.func('_draw_course_clear')
3638
+ gd.line('var screen_size = _canvas.size')
3639
+ gd.line('_canvas.draw_rect(Rect2(0, 0, screen_size.x, screen_size.y), Color(0, 0, 0, 0.7))')
3640
+ gd.line('# Course clear text would use a sprite in production')
3641
+ gd.func_close()
3642
+ gd.blank()
3643
+
3644
+ # Public API
3645
+ gd.func('update_label', 'label_name: String, value')
3646
+ gd.line('match label_name:')
3647
+ for counter in all_counters:
3648
+ bind = counter['bind']
3649
+ gd.raw(f'\t\t"{bind}":')
3650
+ gd.raw(f'\t\t\t{bind} = int(value)')
3651
+ if counter.get('extra_life_at'):
3652
+ threshold = counter['extra_life_at']
3653
+ gd.raw(f'\t\t\twhile {bind} >= {threshold}:')
3654
+ gd.raw(f'\t\t\t\t{bind} -= {threshold}')
3655
+ gd.raw(f'\t\t\t\tlives += 1')
3656
+ for col in all_collections:
3657
+ bind = col['bind']
3658
+ gd.raw(f'\t\t"{bind}":')
3659
+ gd.raw(f'\t\t\t{bind} = int(value)')
3660
+ for slot in all_slots:
3661
+ bind = slot['bind']
3662
+ gd.raw(f'\t\t"{bind}":')
3663
+ gd.raw(f'\t\t\t{bind} = str(value)')
3664
+ gd.func_close()
3665
+ gd.blank()
3666
+
3667
+ gd.func('set_time', 'value: int')
3668
+ for counter in all_counters:
3669
+ if counter.get('countdown'):
3670
+ bind = counter['bind']
3671
+ gd.line(f'{bind} = value')
3672
+ gd.func_close()
3673
+ gd.blank()
3674
+
3675
+ gd.func('stop_timer')
3676
+ gd.line('_countdown_active = false')
3677
+ gd.func_close()
3678
+ gd.blank()
3679
+
3680
+ gd.func('start_bonus_countdown')
3681
+ gd.line('_countdown_active = false')
3682
+ gd.line('_bonus_countdown_active = true')
3683
+ gd.func_close()
3684
+ gd.blank()
3685
+
3686
+ gd.func('show_course_clear')
3687
+ gd.line('_course_clear = true')
3688
+ gd.func_close()
3689
+
3690
+ content = gd.build()
3691
+ (scripts_dir / 'hud_manager.gd').write_text(content, encoding='utf-8')
3692
+
3693
+ def _hud_res_path(self, src: str) -> str:
3694
+ """Convert a source path to a Godot resource path for HUD assets."""
3695
+ return f'res://{src}'
3696
+
3697
+ def _write_game_over_screen(self, scripts_dir: Path, scene: SceneNode):
3698
+ """Write game_over_screen.gd when death_sequence='smw'."""
3699
+ death_sequence = getattr(scene, 'death_sequence', None)
3700
+ if death_sequence != 'smw':
3701
+ return
3702
+
3703
+ alphabet = getattr(scene, 'game_over_alphabet', None) or 'assets/smw/sprites/hud_alphabet.png'
3704
+ jingle = getattr(scene, 'game_over_jingle', None) or 'assets/smw/sounds/smw_game_over.wav'
3705
+ vp_width = getattr(scene, 'viewport_width', None) or SNES_WIDTH
3706
+
3707
+ content = GAME_OVER_SCREEN_GD.format(
3708
+ alphabet_path=_asset_path(alphabet),
3709
+ jingle_path=_asset_path(jingle),
3710
+ viewport_width=vp_width,
3711
+ )
3712
+ (scripts_dir / 'game_over_screen.gd').write_text(content, encoding='utf-8')
3713
+
3714
+ def _resolve_asset_path(self, rel_path: str) -> Optional[Path]:
3715
+ """Resolve a relative asset path by searching upward from source dir."""
3716
+ search_dirs = []
3717
+ if self._source_dir:
3718
+ d = Path(self._source_dir)
3719
+ for _ in range(5):
3720
+ search_dirs.append(d)
3721
+ if d.parent == d:
3722
+ break
3723
+ d = d.parent
3724
+ search_dirs.append(Path.cwd())
3725
+ for base in search_dirs:
3726
+ candidate = base / rel_path
3727
+ if candidate.exists():
3728
+ return candidate
3729
+ return None
3730
+
3731
+ def _write_tilemap_assets(self, out: Path):
3732
+ """Copy external tilemap assets (JSON data, .tres tileset) to output."""
3733
+ import re
3734
+ for tilemap in self._tilemaps:
3735
+ data_src = tilemap.get('data_src')
3736
+ if not data_src:
3737
+ continue
3738
+ # Copy JSON data file (search upward from source dir)
3739
+ src_json = self._resolve_asset_path(data_src)
3740
+ if src_json:
3741
+ dst_json = out / src_json.name
3742
+ if not dst_json.exists():
3743
+ shutil.copy2(str(src_json), str(dst_json))
3744
+ # Copy .tres tileset file and its referenced textures
3745
+ if tilemap['src']:
3746
+ src_tres = self._resolve_asset_path(tilemap['src'])
3747
+ if src_tres:
3748
+ dst_tres = out / src_tres.name
3749
+ if not dst_tres.exists():
3750
+ shutil.copy2(str(src_tres), str(dst_tres))
3751
+ # Parse .tres for ext_resource texture paths and copy them
3752
+ try:
3753
+ tres_text = src_tres.read_text(encoding='utf-8')
3754
+ for m in re.finditer(r'path="res://([^"]+)"', tres_text):
3755
+ res_path = m.group(1)
3756
+ dst_res = out / res_path
3757
+ if not dst_res.exists():
3758
+ src_res = self._resolve_asset_path(res_path)
3759
+ if src_res:
3760
+ dst_res.parent.mkdir(parents=True, exist_ok=True)
3761
+ shutil.copy2(str(src_res), str(dst_res))
3762
+ except Exception:
3763
+ pass
3764
+
3765
+ def _write_tilemap_loader(self, scripts_dir: Path):
3766
+ """Write tilemap_loader.gd script for external JSON data mode."""
3767
+ has_data_src = any(t.get('data_src') for t in self._tilemaps)
3768
+ if not has_data_src:
3769
+ return
3770
+ # Use the first tilemap with data_src to determine the JSON path
3771
+ for tilemap in self._tilemaps:
3772
+ if tilemap.get('data_src'):
3773
+ json_name = Path(tilemap['data_src']).name
3774
+ json_path = f'res://{json_name}'
3775
+ content = TILEMAP_LOADER_GD.format(
3776
+ json_path=json_path,
3777
+ source_id=0,
3778
+ )
3779
+ (scripts_dir / 'tilemap_loader.gd').write_text(content, encoding='utf-8')
3780
+ break
3781
+
3782
+ def _write_export_presets(self, out: Path):
3783
+ """Write export_presets.cfg for web export."""
3784
+ content = build_export_presets()
3785
+ (out / 'export_presets.cfg').write_text(content, encoding='utf-8')
3786
+
3787
+ def _copy_assets(self, out: Path):
3788
+ """Copy all referenced asset files (sprites, sounds) to the output directory.
3789
+
3790
+ Assets are resolved by searching upward from the source .q file directory
3791
+ until the asset path is found. Falls back to CWD if source_dir is not set.
3792
+ The _assets set contains paths like 'assets/smw/sprites/rex_walk.png'.
3793
+ """
3794
+ search_dirs = []
3795
+ if self._source_dir:
3796
+ # Search from source dir, then parent, then grandparent, etc.
3797
+ d = Path(self._source_dir)
3798
+ for _ in range(5):
3799
+ search_dirs.append(d)
3800
+ if d.parent == d:
3801
+ break
3802
+ d = d.parent
3803
+ search_dirs.append(Path.cwd())
3804
+
3805
+ for asset_rel in self._assets:
3806
+ dst = out / asset_rel
3807
+ for base in search_dirs:
3808
+ src = base / asset_rel
3809
+ if src.exists():
3810
+ dst.parent.mkdir(parents=True, exist_ok=True)
3811
+ shutil.copy2(str(src), str(dst))
3812
+ break
3813
+
3814
+ # ------------------------------------------------------------------
3815
+ # Scene Script Generation
3816
+ # ------------------------------------------------------------------
3817
+
3818
+ def _build_scene_script(self, scene: SceneNode) -> str:
3819
+ """Generate the main scene GDScript."""
3820
+ gd = GdScriptBuilder()
3821
+ gd.extends('Node2D')
3822
+ gd.blank()
3823
+
3824
+ # Death sequence support
3825
+ death_sequence = getattr(scene, 'death_sequence', None)
3826
+ death_timer = getattr(scene, 'death_timer', 3.0)
3827
+ has_death = death_sequence is not None
3828
+
3829
+ # Countdown HUD counter detection (for time-up handler)
3830
+ has_countdown = any(
3831
+ counter.get('countdown')
3832
+ for hud in self._huds
3833
+ for counter in hud.get('counters', [])
3834
+ )
3835
+
3836
+ if has_death:
3837
+ gd.var('_is_dead', 'false', 'bool')
3838
+ gd.var('_death_timer', f'{death_timer}', 'float')
3839
+ gd.blank()
3840
+
3841
+ # Infer enemy stomp behavior from EnemyNodes first, then PrefabNode fallback
3842
+ has_stomp_enemies = False
3843
+ stomp_prefabs = []
3844
+ stomp_enemies = [] # EnemyNode-based stomp data
3845
+ for enemy in self._enemies:
3846
+ if enemy.defeat and enemy.defeat.by == 'stomp':
3847
+ has_stomp_enemies = True
3848
+ stomp_enemies.append(enemy)
3849
+ # Legacy fallback: PrefabNode attrs (no EnemyNode defined)
3850
+ if not stomp_enemies:
3851
+ for pname, prefab in self._prefabs.items():
3852
+ if prefab.defeated_by == 'stomp':
3853
+ has_stomp_enemies = True
3854
+ stomp_prefabs.append(prefab)
3855
+
3856
+ # Collect implicit state variables from event actions and HUD bindings
3857
+ declared_vars = {sv['name'] for sv in self._state_vars}
3858
+ for evt in self._events:
3859
+ for action in evt.get('actions', []):
3860
+ if action['action_type'] == 'set' and action.get('target'):
3861
+ varname = action['target']
3862
+ if varname not in declared_vars:
3863
+ self._state_vars.append({'name': varname, 'value': '0'})
3864
+ declared_vars.add(varname)
3865
+ elif action['action_type'] == 'score':
3866
+ if 'score' not in declared_vars:
3867
+ self._state_vars.append({'name': 'score', 'value': '0'})
3868
+ declared_vars.add('score')
3869
+ for hud in self._huds:
3870
+ for counter in hud.get('counters', []):
3871
+ bind = counter.get('bind', '')
3872
+ if bind and bind not in declared_vars:
3873
+ default_val = '300' if counter.get('countdown') else '0'
3874
+ self._state_vars.append({'name': bind, 'value': default_val})
3875
+ declared_vars.add(bind)
3876
+ for collection in hud.get('collections', []):
3877
+ bind = collection.get('bind', '')
3878
+ if bind and bind not in declared_vars:
3879
+ self._state_vars.append({'name': bind, 'value': '0'})
3880
+ declared_vars.add(bind)
3881
+
3882
+ # Collect HUD binds for later use in event action handlers
3883
+ self._hud_binds = set()
3884
+ for hud in self._huds:
3885
+ for counter in hud.get('counters', []):
3886
+ if counter.get('bind'):
3887
+ self._hud_binds.add(counter['bind'])
3888
+ for collection in hud.get('collections', []):
3889
+ if collection.get('bind'):
3890
+ self._hud_binds.add(collection['bind'])
3891
+
3892
+ # Auto-declare 'score' if stomp enemies present (stomp awards score)
3893
+ if has_stomp_enemies and 'score' not in declared_vars:
3894
+ self._state_vars.append({'name': 'score', 'value': '0'})
3895
+ declared_vars.add('score')
3896
+
3897
+ # Auto-declare 'lives' for SMW death sequence (default 3)
3898
+ if has_death and death_sequence == 'smw':
3899
+ if 'lives' not in declared_vars:
3900
+ self._state_vars.append({'name': 'lives', 'value': '3'})
3901
+ declared_vars.add('lives')
3902
+ else:
3903
+ # Fix: if lives was auto-declared from HUD bind with 0, override to 3
3904
+ for sv in self._state_vars:
3905
+ if sv['name'] == 'lives' and sv['value'] == '0':
3906
+ sv['value'] = '3'
3907
+ break
3908
+
3909
+ # State variables
3910
+ if self._state_vars:
3911
+ gd.comment('State variables')
3912
+ for sv in self._state_vars:
3913
+ gd.var(sv['name'], sv['value'])
3914
+ gd.blank()
3915
+
3916
+ # Checkpoint support
3917
+ if has_death and death_sequence == 'smw':
3918
+ gd.var('has_checkpoint', 'false', 'bool')
3919
+ gd.var('checkpoint_position', 'Vector2.ZERO', 'Vector2')
3920
+ gd.blank()
3921
+
3922
+ # _ready
3923
+ gd.func('_ready')
3924
+ # Register all game entities with bridge (sprites + prefab instances)
3925
+ has_prefab_instances = bool(self._instances)
3926
+ if has_prefab_instances:
3927
+ gd.comment('Register all game entities with the bridge')
3928
+ gd.for_block('child', 'get_children()')
3929
+ gd.if_block('child.has_meta("tag") or child is CharacterBody2D or child is StaticBody2D')
3930
+ gd.line('QuantumBridge.register_sprite(child.name, child)')
3931
+ gd.block_close()
3932
+ gd.block_close()
3933
+ else:
3934
+ # No prefab instances — register declared sprites individually
3935
+ for sprite in self._sprites:
3936
+ sid = sprite['id']
3937
+ gd.line(f'QuantumBridge.register_sprite("{sid}", {_gd_node_ref(sid)})')
3938
+ gd.blank()
3939
+
3940
+ # Event listeners — both explicit handlers and auto-generated action handlers
3941
+ if self._events:
3942
+ gd.comment('Event listeners')
3943
+ for evt in self._events:
3944
+ handler = evt['handler']
3945
+ actions = evt.get('actions', [])
3946
+ if handler:
3947
+ gd.line(f'QuantumEventBus.listen("{evt["name"]}", Callable(self, "{handler}"))')
3948
+ elif actions:
3949
+ # Auto-generate handler name from event name
3950
+ safe_name = evt['name'].replace('-', '_')
3951
+ gd.line(f'QuantumEventBus.listen("{evt["name"]}", Callable(self, "_on_{safe_name}"))')
3952
+ gd.blank()
3953
+
3954
+ # Auto-listen for enemy-collision if we have stomp enemies
3955
+ if has_stomp_enemies:
3956
+ gd.line('QuantumEventBus.listen("enemy-collision", Callable(self, "_on_enemy_collision"))')
3957
+ gd.blank()
3958
+
3959
+ # Auto-listen for fell-in-pit if sprites have death collision and death sequence exists
3960
+ has_death_collision = any(
3961
+ col.get('with_tag') == 'death'
3962
+ for sprite in self._sprites
3963
+ for col in sprite.get('on_collisions', [])
3964
+ )
3965
+ if has_death_collision and has_death:
3966
+ gd.line('QuantumEventBus.listen("fell-in-pit", Callable(self, "_on_fell_in_pit"))')
3967
+ gd.blank()
3968
+
3969
+ # Auto-listen for time-up if countdown HUD exists
3970
+ has_countdown = any(
3971
+ counter.get('countdown')
3972
+ for hud in self._huds
3973
+ for counter in hud.get('counters', [])
3974
+ )
3975
+ if has_countdown and has_death:
3976
+ gd.line('QuantumEventBus.listen("time-up", Callable(self, "_on_time_up"))')
3977
+ gd.blank()
3978
+
3979
+ # Timer connections
3980
+ for timer in self._timers:
3981
+ gd.line(f'${timer["id"]}.timeout.connect(Callable(self, "{timer["action"]}"))')
3982
+
3983
+ # Collision signal connections (one connect per sprite, not per on_collision)
3984
+ for sprite in self._sprites:
3985
+ if sprite.get('on_collisions'):
3986
+ sid = sprite['id']
3987
+ gd.line(f'{_gd_node_ref(sid + "_area")}.body_entered.connect(_on_{sid}_collision)')
3988
+ gd.line(f'{_gd_node_ref(sid + "_area")}.area_entered.connect(_on_{sid}_area_entered)')
3989
+
3990
+ # Tweens (auto-start)
3991
+ if self._tweens:
3992
+ gd.blank()
3993
+ gd.comment('Tweens')
3994
+ for tw in self._tweens:
3995
+ if tw['auto_start']:
3996
+ target = tw['target']
3997
+ prop = _quantum_prop_to_godot(tw['property'])
3998
+ to_val = tw['to_value']
3999
+ dur = tw['duration']
4000
+ ease = EASING_MAP.get(tw['easing'], 'Tween.EASE_IN_OUT')
4001
+ trans = TRANS_MAP.get(tw['easing'], 'Tween.TRANS_LINEAR')
4002
+ gd.line(f'var tween_{tw["id"]} = create_tween()')
4003
+ gd.line(f'tween_{tw["id"]}.tween_property(${target}, "{prop}", {to_val}, {dur}).set_ease({ease}).set_trans({trans})')
4004
+ if tw['loop']:
4005
+ gd.line(f'tween_{tw["id"]}.set_loops()')
4006
+
4007
+ # Tilemap setup (only for inline CSV tilemaps; data_src tilemaps use loader script)
4008
+ csv_tilemaps = [t for t in self._tilemaps if not t.get('data_src')]
4009
+ if csv_tilemaps:
4010
+ gd.blank()
4011
+ gd.comment('Tilemap setup')
4012
+ gd.line('_setup_tilemaps()')
4013
+
4014
+ # Connect checkpoint signals
4015
+ has_checkpoint_prefab = any(
4016
+ p.checkpoint or p.name == 'checkpoint' or (hasattr(p, 'tag') and getattr(p, 'tag', None) == 'checkpoint')
4017
+ for p in self._prefabs.values()
4018
+ )
4019
+ if has_checkpoint_prefab and has_death and death_sequence == 'smw':
4020
+ gd.blank()
4021
+ gd.comment('Connect checkpoint signals')
4022
+ gd.for_block('child', 'get_children()')
4023
+ gd.if_block('child.has_signal("checkpoint_activated")')
4024
+ gd.line('child.checkpoint_activated.connect(_on_checkpoint_activated)')
4025
+ gd.block_close()
4026
+ gd.block_close()
4027
+
4028
+ # Game init (state restore, iris_in, HUD update, BGM)
4029
+ if has_death and death_sequence == 'smw':
4030
+ gd.blank()
4031
+ gd.line('on_game_init()')
4032
+
4033
+ if not self._sprites and not self._events and not self._timers and not self._tilemaps and not has_death:
4034
+ gd.pass_stmt()
4035
+
4036
+ gd.func_close()
4037
+ gd.blank()
4038
+
4039
+ # Tilemap setup function (only for inline CSV data)
4040
+ if csv_tilemaps:
4041
+ gd.func('_setup_tilemaps')
4042
+ for tilemap in csv_tilemaps:
4043
+ tid = tilemap['id']
4044
+ tw = tilemap['tile_width']
4045
+ th = tilemap['tile_height']
4046
+
4047
+ for layer in tilemap['layers']:
4048
+ layer_name = f'{tid}_{layer["name"]}'
4049
+ gd.comment(f'Setup {layer_name}')
4050
+ gd.line(f'var tileset_{layer_name} = TileSet.new()')
4051
+ gd.line(f'tileset_{layer_name}.tile_size = {gd_vector2i(tw, th)}')
4052
+
4053
+ if layer['collision']:
4054
+ gd.line(f'tileset_{layer_name}.add_physics_layer()')
4055
+
4056
+ if tilemap['src']:
4057
+ gd.line(f'var source_{layer_name} = TileSetAtlasSource.new()')
4058
+ gd.line(f'source_{layer_name}.texture = load("res://{_asset_path(tilemap["src"])}")')
4059
+ gd.line(f'source_{layer_name}.texture_region_size = {gd_vector2i(tw, th)}')
4060
+ gd.line(f'tileset_{layer_name}.add_source(source_{layer_name})')
4061
+
4062
+ gd.line(f'${layer_name}.tile_set = tileset_{layer_name}')
4063
+ gd.blank()
4064
+
4065
+ # Parse CSV data and set cells
4066
+ gd.comment(f'Populate {layer_name} from data')
4067
+ gd.line(f'var data_{layer_name} = {_csv_to_gd_array(layer["data"])}')
4068
+ gd.for_block('row', f'range(data_{layer_name}.size())')
4069
+ gd.for_block('col', f'range(data_{layer_name}[row].size())')
4070
+ gd.if_block(f'data_{layer_name}[row][col] > 0')
4071
+ # tile_id = data[row][col] - 1 (convert from 1-based to 0-based)
4072
+ gd.line(f'var tile_id = data_{layer_name}[row][col] - 1')
4073
+ # Calculate atlas coords from tile_id
4074
+ # This depends on tileset width; default to simple approach
4075
+ gd.line(f'${layer_name}.set_cell(Vector2i(col, row), 0, Vector2i(tile_id, 0))')
4076
+ gd.block_close() # if
4077
+ gd.block_close() # for col
4078
+ gd.block_close() # for row
4079
+ gd.blank()
4080
+
4081
+ gd.func_close()
4082
+ gd.blank()
4083
+
4084
+ # on_game_init: state restore, iris_in, HUD update, BGM
4085
+ if has_death and death_sequence == 'smw':
4086
+ gd.func('on_game_init')
4087
+ gd.comment('Restore state from previous life')
4088
+ gd.line('var saved = QuantumBridge.get_game_state()')
4089
+ gd.if_block('saved.size() > 0')
4090
+ for sv in self._state_vars:
4091
+ gd.line(f'if saved.has("{sv["name"]}"): {sv["name"]} = saved["{sv["name"]}"]')
4092
+ gd.line('QuantumBridge.clear_game_state()')
4093
+ gd.block_close()
4094
+ gd.blank()
4095
+ # Iris in
4096
+ gd.line('QuantumBridge.iris_in(0.75)')
4097
+ gd.blank()
4098
+ # Update all HUD labels
4099
+ hud_binds = getattr(self, '_hud_binds', set())
4100
+ if hud_binds:
4101
+ gd.comment('Update HUD')
4102
+ for bind in sorted(hud_binds):
4103
+ gd.line(f'$HUD.update_label("{bind}", str({bind}))')
4104
+ # Set time for countdown counters
4105
+ for hud in self._huds:
4106
+ for counter in hud.get('counters', []):
4107
+ if counter.get('countdown') and counter.get('bind'):
4108
+ gd.line(f'$HUD.set_time({counter["bind"]})')
4109
+ gd.blank()
4110
+ # BGM auto-play for scene.start triggers
4111
+ bgm_sounds = [s for s in self._sounds if s.get('trigger') == 'scene.start']
4112
+ for sound in bgm_sounds:
4113
+ gd.line(f'QuantumBridge.play_sound("{sound["id"]}")')
4114
+ gd.func_close()
4115
+ gd.blank()
4116
+
4117
+ # User-defined functions
4118
+ for func in self._functions:
4119
+ self._emit_function(gd, func)
4120
+
4121
+ # Collision handler functions
4122
+ for sprite in self._sprites:
4123
+ if sprite.get('on_collisions'):
4124
+ sid = sprite['id']
4125
+ gd.func(f'_on_{sid}_collision', 'body: Node')
4126
+ for col in sprite['on_collisions']:
4127
+ with_tag = col.get('with_tag', '')
4128
+ action = col['action']
4129
+ if with_tag:
4130
+ gd.if_block(f'body.is_in_group("{with_tag}")')
4131
+ self._emit_collision_action(gd, action, sid)
4132
+ if with_tag:
4133
+ gd.block_close()
4134
+ gd.func_close()
4135
+ gd.blank()
4136
+
4137
+ # Area2D area_entered handler (delegates to body collision via parent)
4138
+ gd.func(f'_on_{sid}_area_entered', 'area: Area2D')
4139
+ gd.comment('Area2D parents are the actual game objects')
4140
+ gd.line(f'_on_{sid}_collision(area.get_parent())')
4141
+ gd.func_close()
4142
+ gd.blank()
4143
+
4144
+ # --- Semantic: fell-in-pit handler ---
4145
+ if has_death_collision and has_death:
4146
+ gd.func('_on_fell_in_pit', '_data: Dictionary = {}')
4147
+ gd.if_block('_is_dead')
4148
+ gd.return_stmt()
4149
+ gd.block_close()
4150
+ gd.line('_kill_player()')
4151
+ gd.func_close()
4152
+ gd.blank()
4153
+
4154
+ # --- Semantic: time-up handler ---
4155
+ if has_countdown and has_death:
4156
+ gd.func('_on_time_up', '_data: Dictionary = {}')
4157
+ gd.if_block('_is_dead')
4158
+ gd.return_stmt()
4159
+ gd.block_close()
4160
+ gd.line('_kill_player()')
4161
+ gd.func_close()
4162
+ gd.blank()
4163
+
4164
+ # --- Semantic: stomp detection ---
4165
+ if has_stomp_enemies:
4166
+ gd.func('_on_enemy_collision', 'data: Dictionary')
4167
+ # Death guard
4168
+ gd.if_block('_is_dead')
4169
+ gd.return_stmt()
4170
+ gd.block_close()
4171
+ gd.line('var enemy = data.get("other")')
4172
+ gd.line('var normal_y = data.get("normal_y", 0.0)')
4173
+ gd.if_block('enemy == null')
4174
+ gd.return_stmt()
4175
+ gd.block_close()
4176
+ gd.comment('Stomp detection: player falling onto enemy from above')
4177
+ gd.if_block('normal_y < -0.5')
4178
+ # Cooldown check (500ms)
4179
+ gd.comment('Stomp cooldown (500ms)')
4180
+ gd.line('var now = Time.get_ticks_msec()')
4181
+ gd.if_block('enemy.has_meta("last_stomp_time") and now - enemy.get_meta("last_stomp_time") < 500')
4182
+ gd.return_stmt()
4183
+ gd.block_close()
4184
+ gd.line('enemy.set_meta("last_stomp_time", now)')
4185
+ gd.blank()
4186
+ gd.comment('Sound + camera shake')
4187
+ gd.line('QuantumBridge.play_sound("sfx-stomp")')
4188
+ gd.line('QuantumBridge.camera_shake(3.0, 0.15)')
4189
+ gd.blank()
4190
+
4191
+ hud_binds = getattr(self, '_hud_binds', set())
4192
+
4193
+ if stomp_enemies:
4194
+ # Data-driven stomp from EnemyNode definitions
4195
+ gd.comment('Data-driven stomp (from <qg:enemy> definitions)')
4196
+ for enemy in stomp_enemies:
4197
+ prefab_name = enemy.prefab
4198
+ meta_key = f'{prefab_name}_squished'
4199
+ defeat = enemy.defeat
4200
+ hit_score = defeat.on_hit.score if defeat and defeat.on_hit else 100
4201
+ kill_score = defeat.on_kill.score if defeat and defeat.on_kill else 200
4202
+ hit_effect = defeat.on_hit.effect if defeat and defeat.on_hit else 'squish'
4203
+ kill_effect = defeat.on_kill.effect if defeat and defeat.on_kill else 'puff'
4204
+ speed_mult = defeat.on_hit.speed_multiply if defeat and defeat.on_hit and defeat.on_hit.speed_multiply else None
4205
+ bounce_val = defeat.bounce if defeat and defeat.bounce else -200
4206
+
4207
+ gd.if_block(f'enemy.get_meta("{meta_key}", false)')
4208
+ # Second stomp: kill
4209
+ gd.line(f'score += {kill_score}')
4210
+ if kill_effect == 'puff':
4211
+ gd.line('_spawn_puff(enemy.global_position)')
4212
+ gd.line('QuantumBridge.destroy_sprite(enemy.name)')
4213
+ gd.else_block()
4214
+ # First stomp: squish
4215
+ gd.line(f'enemy.set_meta("{meta_key}", true)')
4216
+ if hit_effect == 'squish':
4217
+ gd.line('var fh = enemy.frame_h if "frame_h" in enemy else 32.0')
4218
+ gd.if_block('enemy.has_node("Sprite2D")')
4219
+ gd.line('enemy.get_node("Sprite2D").scale.y = 0.5')
4220
+ gd.line('enemy.get_node("Sprite2D").position.y += fh * 0.25')
4221
+ gd.block_close()
4222
+ gd.if_block('enemy.has_node("CollisionShape2D")')
4223
+ gd.line('var col_shape = enemy.get_node("CollisionShape2D").shape')
4224
+ gd.if_block('col_shape is RectangleShape2D')
4225
+ gd.line('col_shape.size.y *= 0.5')
4226
+ gd.line('enemy.get_node("CollisionShape2D").position.y += fh * 0.25')
4227
+ gd.block_close()
4228
+ gd.block_close()
4229
+ if speed_mult:
4230
+ gd.if_block('enemy.has_method("set_patrol")')
4231
+ gd.line(f'enemy.set_patrol(enemy.patrol_speed * {speed_mult})')
4232
+ gd.block_close()
4233
+ gd.line(f'score += {hit_score}')
4234
+ gd.block_close()
4235
+ if 'score' in hud_binds:
4236
+ gd.line('$HUD.update_label("score", str(score))')
4237
+
4238
+ # Bounce player
4239
+ gd.comment('Bounce player after stomp')
4240
+ player_id = None
4241
+ for s in self._sprites:
4242
+ if s.get('controls'):
4243
+ player_id = s['id']
4244
+ break
4245
+ if player_id:
4246
+ gd.line(f'var player = QuantumBridge.get_sprite("{player_id}")')
4247
+ gd.if_block('player')
4248
+ gd.if_block('player.has_method("stomp_bounce")')
4249
+ gd.line(f'player.stomp_bounce({bounce_val})')
4250
+ gd.else_block()
4251
+ gd.line(f'player.velocity.y = {bounce_val}')
4252
+ gd.block_close()
4253
+ gd.block_close()
4254
+ else:
4255
+ # Legacy fallback: PrefabNode attrs
4256
+ gd.comment('Two-stage stomp: squish first, kill on second stomp')
4257
+ gd.if_block('enemy.get_meta("rex_squished", false)')
4258
+ # Second stomp: kill + puff
4259
+ gd.line('score += 200')
4260
+ gd.line('_spawn_puff(enemy.global_position)')
4261
+ gd.line('QuantumBridge.destroy_sprite(enemy.name)')
4262
+ gd.else_block()
4263
+ # First stomp: squish
4264
+ gd.line('enemy.set_meta("rex_squished", true)')
4265
+ gd.line('var fh = enemy.frame_h if "frame_h" in enemy else 32.0')
4266
+ gd.if_block('enemy.has_node("Sprite2D")')
4267
+ gd.line('enemy.get_node("Sprite2D").scale.y = 0.5')
4268
+ gd.line('enemy.get_node("Sprite2D").position.y += fh * 0.25')
4269
+ gd.block_close()
4270
+ gd.if_block('enemy.has_node("CollisionShape2D")')
4271
+ gd.line('var col_shape = enemy.get_node("CollisionShape2D").shape')
4272
+ gd.if_block('col_shape is RectangleShape2D')
4273
+ gd.line('col_shape.size.y *= 0.5')
4274
+ gd.line('enemy.get_node("CollisionShape2D").position.y += fh * 0.25')
4275
+ gd.block_close()
4276
+ gd.block_close()
4277
+ gd.if_block('enemy.has_method("set_patrol")')
4278
+ gd.line('enemy.set_patrol(80.0)')
4279
+ gd.block_close()
4280
+ gd.line('score += 100')
4281
+ gd.block_close()
4282
+ if 'score' in hud_binds:
4283
+ gd.line('$HUD.update_label("score", str(score))')
4284
+ # Bounce player
4285
+ any_bounce = False
4286
+ for p in stomp_prefabs:
4287
+ if p.stomp_bounce:
4288
+ any_bounce = True
4289
+ break
4290
+ if any_bounce:
4291
+ bounce_val = stomp_prefabs[0].stomp_bounce or -200
4292
+ gd.comment('Bounce player after stomp')
4293
+ player_id = None
4294
+ for s in self._sprites:
4295
+ if s.get('controls'):
4296
+ player_id = s['id']
4297
+ break
4298
+ if player_id:
4299
+ gd.line(f'var player = QuantumBridge.get_sprite("{player_id}")')
4300
+ gd.if_block('player')
4301
+ gd.if_block('player.has_method("stomp_bounce")')
4302
+ gd.line(f'player.stomp_bounce({bounce_val})')
4303
+ gd.else_block()
4304
+ gd.line(f'player.velocity.y = {bounce_val}')
4305
+ gd.block_close()
4306
+ gd.block_close()
4307
+
4308
+ gd.else_block()
4309
+ gd.comment('Enemy hit player from side/below')
4310
+ gd.line('_kill_player()')
4311
+ gd.block_close()
4312
+ gd.func_close()
4313
+ gd.blank()
4314
+
4315
+ # _spawn_puff helper
4316
+ gd.func('_spawn_puff', 'pos: Vector2')
4317
+ gd.line('var puff = Sprite2D.new()')
4318
+ gd.line('puff.global_position = pos')
4319
+ gd.line('var img = Image.create(8, 8, false, Image.FORMAT_RGBA8)')
4320
+ gd.line('img.fill(Color.WHITE)')
4321
+ gd.line('puff.texture = ImageTexture.create_from_image(img)')
4322
+ gd.line('add_child(puff)')
4323
+ gd.line('var tween = create_tween()')
4324
+ gd.line('tween.set_parallel(true)')
4325
+ gd.line('tween.tween_property(puff, "scale", Vector2(4.0, 4.0), 0.3)')
4326
+ gd.line('tween.tween_property(puff, "modulate:a", 0.0, 0.3)')
4327
+ gd.line('tween.set_parallel(false)')
4328
+ gd.line('tween.tween_callback(puff.queue_free)')
4329
+ gd.func_close()
4330
+ gd.blank()
4331
+
4332
+ # --- Semantic: death sequence ---
4333
+ if has_death:
4334
+ # Find player
4335
+ player_id = None
4336
+ for s in self._sprites:
4337
+ if s.get('controls'):
4338
+ player_id = s['id']
4339
+ break
4340
+
4341
+ gd.func('_kill_player')
4342
+ gd.if_block('_is_dead')
4343
+ gd.return_stmt()
4344
+ gd.block_close()
4345
+ gd.assign('_is_dead', 'true')
4346
+
4347
+ if death_sequence == 'smw':
4348
+ # Decrement lives + HUD update
4349
+ gd.line('lives -= 1')
4350
+ hud_binds = getattr(self, '_hud_binds', set())
4351
+ if 'lives' in hud_binds:
4352
+ gd.line('$HUD.update_label("lives", str(lives))')
4353
+ gd.blank()
4354
+ # Force enemies to pausable so they freeze
4355
+ gd.comment('Freeze enemies')
4356
+ gd.for_block('child', 'get_children()')
4357
+ gd.if_block('child.has_meta("tag") and child.get_meta("tag") == "enemy"')
4358
+ gd.line('child.process_mode = Node.PROCESS_MODE_PAUSABLE')
4359
+ gd.block_close()
4360
+ gd.block_close()
4361
+ gd.blank()
4362
+ # Scene stays processing during pause
4363
+ gd.line('process_mode = Node.PROCESS_MODE_ALWAYS')
4364
+ gd.blank()
4365
+ # Death SFX (must keep playing during pause)
4366
+ gd.line('var sfx = get_node_or_null("Sounds/sfx-death")')
4367
+ gd.if_block('sfx')
4368
+ gd.line('sfx.process_mode = Node.PROCESS_MODE_ALWAYS')
4369
+ gd.line('sfx.play()')
4370
+ gd.block_close()
4371
+ # Stop BGM
4372
+ bgm_sounds = [s for s in self._sounds if s.get('trigger') == 'scene.start']
4373
+ if bgm_sounds:
4374
+ bgm_id = bgm_sounds[0]['id']
4375
+ gd.line(f'var bgm = get_node_or_null("Sounds/{bgm_id}")')
4376
+ gd.if_block('bgm')
4377
+ gd.line('bgm.stop()')
4378
+ gd.block_close()
4379
+ gd.blank()
4380
+ # Kill player visual
4381
+ if player_id:
4382
+ gd.line(f'QuantumBridge.kill_player("{player_id}")')
4383
+ gd.blank()
4384
+ # Pause tree
4385
+ gd.line('get_tree().paused = true')
4386
+ gd.blank()
4387
+ # Timer with PROCESS_MODE_ALWAYS (create_timer doesn't work in pause!)
4388
+ gd.comment('Manual Timer with PROCESS_MODE_ALWAYS (works during pause)')
4389
+ gd.line('var t = Timer.new()')
4390
+ gd.line('t.wait_time = _death_timer')
4391
+ gd.line('t.one_shot = true')
4392
+ gd.line('t.process_mode = Node.PROCESS_MODE_ALWAYS')
4393
+ gd.line('add_child(t)')
4394
+ gd.line('t.timeout.connect(_on_death_complete)')
4395
+ gd.line('t.start()')
4396
+ elif death_sequence == 'instant':
4397
+ gd.line('get_tree().reload_current_scene()')
4398
+ elif death_sequence == 'respawn':
4399
+ if player_id:
4400
+ gd.line(f'QuantumBridge.respawn("{player_id}", 0, 0)')
4401
+ gd.assign('_is_dead', 'false')
4402
+ gd.func_close()
4403
+ gd.blank()
4404
+
4405
+ if death_sequence == 'smw':
4406
+ gd.func('_on_death_complete')
4407
+ # Iris out
4408
+ gd.line('QuantumBridge.iris_out(0.6)')
4409
+ gd.line('var iris_timer = Timer.new()')
4410
+ gd.line('iris_timer.wait_time = 0.7')
4411
+ gd.line('iris_timer.one_shot = true')
4412
+ gd.line('iris_timer.process_mode = Node.PROCESS_MODE_ALWAYS')
4413
+ gd.line('add_child(iris_timer)')
4414
+ gd.line('iris_timer.start()')
4415
+ gd.line('await iris_timer.timeout')
4416
+ gd.blank()
4417
+ # Game over check
4418
+ gd.if_block('lives <= 0')
4419
+ gd.line('_show_game_over()')
4420
+ gd.return_stmt()
4421
+ gd.block_close()
4422
+ gd.blank()
4423
+ # Resume before checkpoint/reload
4424
+ gd.line('get_tree().paused = false')
4425
+ gd.if_block('has_checkpoint')
4426
+ gd.line('_respawn_at_checkpoint()')
4427
+ gd.else_block()
4428
+ # Save state + reload
4429
+ state_dict_parts = []
4430
+ for sv in self._state_vars:
4431
+ state_dict_parts.append(f'"{sv["name"]}": {sv["name"]}')
4432
+ state_dict = '{' + ', '.join(state_dict_parts) + '}'
4433
+ gd.line(f'QuantumBridge.save_game_state({state_dict})')
4434
+ gd.line('get_tree().reload_current_scene()')
4435
+ gd.block_close()
4436
+ gd.func_close()
4437
+ gd.blank()
4438
+
4439
+ # _show_game_over
4440
+ gd.func('_show_game_over')
4441
+ gd.comment('Stop HUD timer')
4442
+ gd.line('$HUD.stop_timer()')
4443
+ gd.blank()
4444
+ gd.line('var go_screen = load("res://scripts/game_over_screen.gd").new()')
4445
+ gd.line('go_screen.process_mode = Node.PROCESS_MODE_ALWAYS')
4446
+ gd.line('add_child(go_screen)')
4447
+ gd.line('await go_screen.game_over_finished')
4448
+ gd.line('go_screen.queue_free()')
4449
+ gd.blank()
4450
+ gd.comment('Fresh restart')
4451
+ gd.line('get_tree().paused = false')
4452
+ gd.line('QuantumBridge.clear_game_state()')
4453
+ gd.line('get_tree().reload_current_scene()')
4454
+ gd.func_close()
4455
+ gd.blank()
4456
+
4457
+ # Checkpoint handlers
4458
+ gd.func('_on_checkpoint_activated', 'spawn_pos: Vector2')
4459
+ gd.assign('has_checkpoint', 'true')
4460
+ gd.assign('checkpoint_position', 'spawn_pos')
4461
+ gd.func_close()
4462
+ gd.blank()
4463
+
4464
+ gd.func('_respawn_at_checkpoint')
4465
+ gd.assign('_is_dead', 'false')
4466
+ if player_id:
4467
+ gd.line(f'var {player_id}_node = {_gd_node_ref(player_id)}')
4468
+ gd.if_block(f'{player_id}_node')
4469
+ gd.line(f'{player_id}_node.is_dead = false')
4470
+ gd.line(f'{player_id}_node.position = checkpoint_position')
4471
+ gd.line(f'{player_id}_node.velocity = Vector2.ZERO')
4472
+ gd.line(f'{player_id}_node.collision_layer = 1')
4473
+ gd.line(f'{player_id}_node.collision_mask = 1 | 2')
4474
+ gd.line(f'{player_id}_node.process_mode = Node.PROCESS_MODE_INHERIT')
4475
+ gd.comment('Restore sprite')
4476
+ gd.line(f'var sprite = {player_id}_node.get_node_or_null("Sprite2D")')
4477
+ gd.if_block(f'sprite and {player_id}_node._normal_texture')
4478
+ gd.line(f'sprite.texture = {player_id}_node._normal_texture')
4479
+ gd.line('sprite.hframes = 5')
4480
+ gd.line('sprite.frame = 0')
4481
+ gd.line('sprite.flip_h = false')
4482
+ gd.block_close()
4483
+ gd.block_close()
4484
+ gd.blank()
4485
+ # Resume BGM
4486
+ bgm_sounds = [s for s in self._sounds if s.get('trigger') == 'scene.start']
4487
+ if bgm_sounds:
4488
+ bgm_id = bgm_sounds[0]['id']
4489
+ gd.line(f'var bgm = get_node_or_null("Sounds/{bgm_id}")')
4490
+ gd.if_block('bgm')
4491
+ gd.line('bgm.play()')
4492
+ gd.block_close()
4493
+ gd.blank()
4494
+ gd.line('QuantumBridge.iris_in(0.5)')
4495
+ hud_binds = getattr(self, '_hud_binds', set())
4496
+ if 'lives' in hud_binds:
4497
+ gd.line('$HUD.update_label("lives", str(lives))')
4498
+ gd.func_close()
4499
+ gd.blank()
4500
+
4501
+ # --- Semantic: event action handlers ---
4502
+ for evt in self._events:
4503
+ actions = evt.get('actions', [])
4504
+ if actions and not evt['handler']:
4505
+ safe_name = evt['name'].replace('-', '_')
4506
+ gd.func(f'_on_{safe_name}', 'data: Dictionary = {}')
4507
+ for action in actions:
4508
+ self._emit_event_action(gd, action)
4509
+ gd.func_close()
4510
+ gd.blank()
4511
+
4512
+ return gd.build()
4513
+
4514
+ def _emit_event_action(self, gd: GdScriptBuilder, action: dict):
4515
+ """Emit GDScript code for a single event action."""
4516
+ atype = action['action_type']
4517
+ target = action.get('target')
4518
+ value = action.get('value')
4519
+ amount = action.get('amount')
4520
+
4521
+ if atype == 'score':
4522
+ amt = amount or 0
4523
+ hud_binds = getattr(self, '_hud_binds', set())
4524
+ if 'score' in hud_binds:
4525
+ gd.line(f'score += {amt}')
4526
+ gd.line(f'$HUD.update_label("score", str(score))')
4527
+ else:
4528
+ gd.line(f'QuantumEventBus.emit_event("score-changed", {{"amount": {amt}}})')
4529
+ elif atype == 'destroy':
4530
+ if target and '{data' in target:
4531
+ # Dynamic target from event data
4532
+ key = target.replace('{', '').replace('}', '')
4533
+ gd.line(f'var target = {key}')
4534
+ gd.if_block('target and target.has_method("queue_free")')
4535
+ gd.line('target.queue_free()')
4536
+ gd.block_close()
4537
+ elif target:
4538
+ gd.line(f'QuantumBridge.destroy_sprite("{target}")')
4539
+ elif atype == 'sound':
4540
+ sound_id = target or ''
4541
+ gd.line(f'QuantumBridge.play_sound("{sound_id}")')
4542
+ elif atype == 'camera-shake':
4543
+ if value:
4544
+ parts = value.split(',')
4545
+ intensity = parts[0].strip() if len(parts) > 0 else '5.0'
4546
+ duration = parts[1].strip() if len(parts) > 1 else '0.3'
4547
+ gd.line(f'QuantumBridge.camera_shake({intensity}, {duration})')
4548
+ else:
4549
+ gd.line('QuantumBridge.camera_shake()')
4550
+ elif atype == 'set':
4551
+ if target and value:
4552
+ # Simple set: target is variable name
4553
+ gd.assign(target, str(value).replace('{', '').replace('}', ''))
4554
+ elif target:
4555
+ gd.assign(target, '0')
4556
+ # Update HUD if variable is bound
4557
+ hud_binds = getattr(self, '_hud_binds', set())
4558
+ if target and target in hud_binds:
4559
+ gd.line(f'$HUD.update_label("{target}", str({target}))')
4560
+ elif atype == 'emit':
4561
+ event_name = value or ''
4562
+ gd.line(f'QuantumEventBus.emit_event("{event_name}", data)')
4563
+ elif atype == 'spawn':
4564
+ gd.comment(f'spawn: {target} at {value}')
4565
+
4566
+ def _emit_function(self, gd: GdScriptBuilder, func: FunctionNode):
4567
+ """Emit a user-defined function."""
4568
+ name = func.name
4569
+ params = ''
4570
+ # Check for lang attribute to determine if it's GDScript
4571
+ lang = getattr(func, 'lang', None)
4572
+
4573
+ gd.func(name, 'data: Dictionary = {}')
4574
+
4575
+ # Get function body
4576
+ body = getattr(func, 'body', [])
4577
+ if isinstance(body, str):
4578
+ # Raw code string
4579
+ for line in body.strip().split('\n'):
4580
+ gd.line(line.strip())
4581
+ elif isinstance(body, list):
4582
+ has_content = False
4583
+ for child in body:
4584
+ if isinstance(child, RawCodeNode):
4585
+ for line in child.code.strip().split('\n'):
4586
+ gd.line(line.strip())
4587
+ has_content = True
4588
+ elif isinstance(child, SetNode):
4589
+ gd.assign(child.name, str(child.value))
4590
+ has_content = True
4591
+ if not has_content:
4592
+ gd.pass_stmt()
4593
+ else:
4594
+ gd.pass_stmt()
4595
+
4596
+ gd.func_close()
4597
+ gd.blank()
4598
+
4599
+ def _emit_collision_action(self, gd: GdScriptBuilder, action: str, sprite_id: str):
4600
+ """Emit code for a collision action. Supports compound actions separated by ';'."""
4601
+ parts = [a.strip() for a in action.split(';')]
4602
+ for part in parts:
4603
+ self._emit_single_collision_action(gd, part, sprite_id)
4604
+
4605
+ def _emit_single_collision_action(self, gd: GdScriptBuilder, action: str, sprite_id: str):
4606
+ """Emit code for a single collision action."""
4607
+ if action == 'destroy-self':
4608
+ gd.line(f'QuantumBridge.destroy_sprite("{sprite_id}")')
4609
+ elif action == 'destroy-other' or action == 'destroy':
4610
+ gd.line('body.queue_free()')
4611
+ elif action.startswith('emit:'):
4612
+ event_name = action[5:]
4613
+ gd.line(f'QuantumBridge.emit_event("{event_name}", {{"self_id": "{sprite_id}", "other": body}})')
4614
+ elif action:
4615
+ gd.comment(f'Unknown action: {action}')
4616
+
4617
+
4618
+ # ==========================================================================
4619
+ # Utility functions
4620
+ # ==========================================================================
4621
+
4622
+ def _gd_node_ref(node_id: str) -> str:
4623
+ """Build a safe GDScript node reference ($name or $"name-with-hyphens")."""
4624
+ if '-' in node_id or ' ' in node_id:
4625
+ return f'$"{node_id}"'
4626
+ return f'${node_id}'
4627
+
4628
+
4629
+ def _node_path(parent: str, child: str) -> str:
4630
+ """Build a Godot scene tree path."""
4631
+ if parent == '.':
4632
+ return child
4633
+ return f'{parent}/{child}'
4634
+
4635
+
4636
+ def _asset_path(src: str) -> str:
4637
+ """Normalize asset path — avoid double 'assets/' prefix.
4638
+
4639
+ If src already starts with 'assets/', keep as-is.
4640
+ Otherwise prepend 'assets/'.
4641
+ """
4642
+ if src.startswith('assets/') or src.startswith('assets\\'):
4643
+ return src
4644
+ return f'assets/{src}'
4645
+
4646
+
4647
+ def _csv_to_gd_array(csv_data: str) -> str:
4648
+ """Convert CSV tile data to GDScript 2D array literal."""
4649
+ rows = []
4650
+ for line in csv_data.strip().split('\n'):
4651
+ line = line.strip().rstrip(',')
4652
+ if not line:
4653
+ continue
4654
+ cells = [c.strip() for c in line.split(',') if c.strip()]
4655
+ rows.append('[' + ', '.join(cells) + ']')
4656
+ return '[' + ', '.join(rows) + ']'
4657
+
4658
+
4659
+ def _quantum_prop_to_godot(prop: str) -> str:
4660
+ """Map Quantum property names to Godot property paths."""
4661
+ mapping = {
4662
+ 'x': 'position:x',
4663
+ 'y': 'position:y',
4664
+ 'alpha': 'modulate:a',
4665
+ 'rotation': 'rotation',
4666
+ 'scale': 'scale:x',
4667
+ 'scale_x': 'scale:x',
4668
+ 'scale_y': 'scale:y',
4669
+ }
4670
+ return mapping.get(prop, prop)
4671
+
4672
+
4673
+ def _sanitize_name(name: str) -> str:
4674
+ """Convert a scene name like 'world-map' to 'world_map' for filenames."""
4675
+ return name.replace('-', '_').replace(' ', '_').lower()
4676
+
4677
+
4678
+ def _pascal_case(name: str) -> str:
4679
+ """Convert 'world-map' or 'world_map' to 'WorldMap' for Godot node names."""
4680
+ parts = name.replace('-', '_').split('_')
4681
+ return ''.join(p.capitalize() for p in parts)