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.
- quantum/__init__.py +1 -0
- quantum/cli/__init__.py +2 -0
- quantum/cli/commands/__init__.py +15 -0
- quantum/cli/commands/build.py +417 -0
- quantum/cli/commands/dev.py +185 -0
- quantum/cli/commands/docs.py +329 -0
- quantum/cli/commands/lint.py +523 -0
- quantum/cli/commands/migrate.py +527 -0
- quantum/cli/commands/new.py +622 -0
- quantum/cli/commands/serve.py +190 -0
- quantum/cli/commands/test.py +193 -0
- quantum/cli/deploy.py +810 -0
- quantum/cli/hot_reload.py +951 -0
- quantum/cli/jobs.py +356 -0
- quantum/cli/mq.py +582 -0
- quantum/cli/pkg.py +390 -0
- quantum/cli/runner.py +547 -0
- quantum/cli/server_process.py +159 -0
- quantum/cli/utils.py +334 -0
- quantum/compiler/__init__.py +30 -0
- quantum/compiler/base_generator.py +367 -0
- quantum/compiler/cli.py +295 -0
- quantum/compiler/expression_transformer.py +444 -0
- quantum/compiler/javascript/__init__.py +10 -0
- quantum/compiler/javascript/generator.py +659 -0
- quantum/compiler/optimizer.py +270 -0
- quantum/compiler/python/__init__.py +10 -0
- quantum/compiler/python/generator.py +883 -0
- quantum/compiler/python/runtime.py +863 -0
- quantum/compiler/transpiler.py +330 -0
- quantum/core/__init__.py +3 -0
- quantum/core/ast_nodes.py +2611 -0
- quantum/core/expression_diagnostics.py +87 -0
- quantum/core/expression_stdlib.py +148 -0
- quantum/core/expressions.py +591 -0
- quantum/core/features/agents/src/__init__.py +30 -0
- quantum/core/features/agents/src/ast_node.py +540 -0
- quantum/core/features/conditionals/src/__init__.py +8 -0
- quantum/core/features/conditionals/src/ast_node.py +68 -0
- quantum/core/features/data_fetching/src/__init__.py +21 -0
- quantum/core/features/data_fetching/src/ast_node.py +312 -0
- quantum/core/features/data_fetching/src/desktop_adapter.py +351 -0
- quantum/core/features/data_fetching/src/html_adapter.py +474 -0
- quantum/core/features/data_fetching/src/parser.py +225 -0
- quantum/core/features/data_import/src/__init__.py +0 -0
- quantum/core/features/data_import/src/ast_node.py +291 -0
- quantum/core/features/data_import/src/runtime.py +538 -0
- quantum/core/features/dump/src/__init__.py +12 -0
- quantum/core/features/dump/src/ast_node.py +106 -0
- quantum/core/features/dump/src/parser.py +61 -0
- quantum/core/features/dump/src/runtime.py +246 -0
- quantum/core/features/functions/src/__init__.py +8 -0
- quantum/core/features/functions/src/ast_node.py +149 -0
- quantum/core/features/game_engine_2d/src/__init__.py +19 -0
- quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
- quantum/core/features/game_engine_2d/src/parser.py +983 -0
- quantum/core/features/invocation/src/__init__.py +0 -0
- quantum/core/features/invocation/src/ast_node.py +146 -0
- quantum/core/features/invocation/src/runtime.py +327 -0
- quantum/core/features/knowledge_base/src/__init__.py +6 -0
- quantum/core/features/knowledge_base/src/ast_node.py +113 -0
- quantum/core/features/knowledge_base/src/parser.py +82 -0
- quantum/core/features/logging/src/__init__.py +12 -0
- quantum/core/features/logging/src/ast_node.py +111 -0
- quantum/core/features/logging/src/parser.py +50 -0
- quantum/core/features/logging/src/runtime.py +190 -0
- quantum/core/features/loops/src/__init__.py +8 -0
- quantum/core/features/loops/src/ast_node.py +60 -0
- quantum/core/features/query/src/__init__.py +0 -0
- quantum/core/features/query/src/database_service.py +322 -0
- quantum/core/features/query/src/query_validators.py +20 -0
- quantum/core/features/state_management/src/__init__.py +11 -0
- quantum/core/features/state_management/src/ast_node.py +228 -0
- quantum/core/features/terminal_engine/src/__init__.py +21 -0
- quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
- quantum/core/features/terminal_engine/src/parser.py +361 -0
- quantum/core/features/testing_engine/src/__init__.py +41 -0
- quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
- quantum/core/features/testing_engine/src/parser.py +604 -0
- quantum/core/features/theming/src/__init__.py +48 -0
- quantum/core/features/theming/src/ast_node.py +137 -0
- quantum/core/features/theming/src/presets.py +405 -0
- quantum/core/features/ui_engine/src/__init__.py +20 -0
- quantum/core/features/ui_engine/src/ast_nodes.py +1854 -0
- quantum/core/features/ui_engine/src/parser.py +1106 -0
- quantum/core/features/websocket/src/__init__.py +24 -0
- quantum/core/features/websocket/src/ast_node.py +247 -0
- quantum/core/html_compat.py +299 -0
- quantum/core/parser.py +1235 -0
- quantum/core/parser_registry.py +213 -0
- quantum/core/parsers/__init__.py +76 -0
- quantum/core/parsers/ai/__init__.py +12 -0
- quantum/core/parsers/ai/agent_parser.py +106 -0
- quantum/core/parsers/ai/knowledge_parser.py +86 -0
- quantum/core/parsers/ai/llm_parser.py +78 -0
- quantum/core/parsers/ai/team_parser.py +88 -0
- quantum/core/parsers/base.py +322 -0
- quantum/core/parsers/composition/__init__.py +10 -0
- quantum/core/parsers/composition/import_parser.py +50 -0
- quantum/core/parsers/composition/slot_parser.py +48 -0
- quantum/core/parsers/control_flow/__init__.py +11 -0
- quantum/core/parsers/control_flow/if_parser.py +68 -0
- quantum/core/parsers/control_flow/loop_parser.py +105 -0
- quantum/core/parsers/control_flow/set_parser.py +89 -0
- quantum/core/parsers/data/__init__.py +12 -0
- quantum/core/parsers/data/data_parser.py +217 -0
- quantum/core/parsers/data/invoke_parser.py +116 -0
- quantum/core/parsers/data/query_parser.py +188 -0
- quantum/core/parsers/data/transaction_parser.py +77 -0
- quantum/core/parsers/events/__init__.py +9 -0
- quantum/core/parsers/events/dispatch_event_parser.py +44 -0
- quantum/core/parsers/forms/__init__.py +11 -0
- quantum/core/parsers/forms/action_parser.py +54 -0
- quantum/core/parsers/forms/flash_parser.py +34 -0
- quantum/core/parsers/forms/redirect_parser.py +33 -0
- quantum/core/parsers/functions/__init__.py +11 -0
- quantum/core/parsers/functions/function_parser.py +137 -0
- quantum/core/parsers/functions/param_parser.py +66 -0
- quantum/core/parsers/functions/return_parser.py +31 -0
- quantum/core/parsers/html/__init__.py +10 -0
- quantum/core/parsers/html/component_call_parser.py +130 -0
- quantum/core/parsers/html/html_parser.py +115 -0
- quantum/core/parsers/jobs/__init__.py +11 -0
- quantum/core/parsers/jobs/job_parser.py +71 -0
- quantum/core/parsers/jobs/schedule_parser.py +62 -0
- quantum/core/parsers/jobs/thread_parser.py +57 -0
- quantum/core/parsers/messaging/__init__.py +17 -0
- quantum/core/parsers/messaging/message_ack_parser.py +30 -0
- quantum/core/parsers/messaging/message_nack_parser.py +30 -0
- quantum/core/parsers/messaging/message_parser.py +114 -0
- quantum/core/parsers/messaging/queue_parser.py +61 -0
- quantum/core/parsers/messaging/websocket_parser.py +121 -0
- quantum/core/parsers/persistence/__init__.py +9 -0
- quantum/core/parsers/persistence/persist_parser.py +64 -0
- quantum/core/parsers/routing/__init__.py +9 -0
- quantum/core/parsers/routing/route_parser.py +41 -0
- quantum/core/parsers/scripting/__init__.py +12 -0
- quantum/core/parsers/scripting/pyclass_parser.py +62 -0
- quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
- quantum/core/parsers/scripting/pyimport_parser.py +52 -0
- quantum/core/parsers/scripting/python_parser.py +49 -0
- quantum/core/parsers/services/__init__.py +12 -0
- quantum/core/parsers/services/dump_parser.py +52 -0
- quantum/core/parsers/services/file_parser.py +48 -0
- quantum/core/parsers/services/log_parser.py +46 -0
- quantum/core/parsers/services/mail_parser.py +65 -0
- quantum/core/tiers.py +82 -0
- quantum/packages/__init__.py +28 -0
- quantum/packages/manager.py +413 -0
- quantum/packages/manifest.py +351 -0
- quantum/packages/registry.py +399 -0
- quantum/packages/resolver.py +336 -0
- quantum/plugins/__init__.py +33 -0
- quantum/plugins/hooks.py +329 -0
- quantum/plugins/loader.py +479 -0
- quantum/plugins/manifest.py +336 -0
- quantum/plugins/registry.py +371 -0
- quantum/runtime/__init__.py +28 -0
- quantum/runtime/action_handler.py +443 -0
- quantum/runtime/adapters/__init__.py +88 -0
- quantum/runtime/adapters/memory_adapter.py +690 -0
- quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
- quantum/runtime/adapters/redis_adapter.py +582 -0
- quantum/runtime/adapters/sqlite_adapter.py +414 -0
- quantum/runtime/agent_service.py +1133 -0
- quantum/runtime/api_server.py +86 -0
- quantum/runtime/ast_cache.py +506 -0
- quantum/runtime/auth_service.py +267 -0
- quantum/runtime/component.py +990 -0
- quantum/runtime/component_composer.py +319 -0
- quantum/runtime/component_resolver.py +174 -0
- quantum/runtime/database_service.py +598 -0
- quantum/runtime/email_service.py +162 -0
- quantum/runtime/error_handler.py +295 -0
- quantum/runtime/execution_context.py +286 -0
- quantum/runtime/executor_registry.py +171 -0
- quantum/runtime/executors/__init__.py +71 -0
- quantum/runtime/executors/ai/__init__.py +12 -0
- quantum/runtime/executors/ai/agent_executor.py +217 -0
- quantum/runtime/executors/ai/knowledge_executor.py +114 -0
- quantum/runtime/executors/ai/llm_executor.py +153 -0
- quantum/runtime/executors/ai/team_executor.py +171 -0
- quantum/runtime/executors/base.py +262 -0
- quantum/runtime/executors/control_flow/__init__.py +11 -0
- quantum/runtime/executors/control_flow/if_executor.py +93 -0
- quantum/runtime/executors/control_flow/loop_executor.py +307 -0
- quantum/runtime/executors/control_flow/set_executor.py +412 -0
- quantum/runtime/executors/data/__init__.py +12 -0
- quantum/runtime/executors/data/data_executor.py +145 -0
- quantum/runtime/executors/data/invoke_executor.py +176 -0
- quantum/runtime/executors/data/query_executor.py +256 -0
- quantum/runtime/executors/data/transaction_executor.py +91 -0
- quantum/runtime/executors/jobs/__init__.py +11 -0
- quantum/runtime/executors/jobs/job_executor.py +190 -0
- quantum/runtime/executors/jobs/schedule_executor.py +132 -0
- quantum/runtime/executors/jobs/thread_executor.py +127 -0
- quantum/runtime/executors/messaging/__init__.py +17 -0
- quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
- quantum/runtime/executors/messaging/message_executor.py +174 -0
- quantum/runtime/executors/messaging/queue_executor.py +103 -0
- quantum/runtime/executors/messaging/websocket_executor.py +197 -0
- quantum/runtime/executors/scripting/__init__.py +11 -0
- quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
- quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
- quantum/runtime/executors/scripting/python_executor.py +249 -0
- quantum/runtime/executors/services/__init__.py +12 -0
- quantum/runtime/executors/services/dump_executor.py +72 -0
- quantum/runtime/executors/services/file_executor.py +89 -0
- quantum/runtime/executors/services/log_executor.py +77 -0
- quantum/runtime/executors/services/mail_executor.py +81 -0
- quantum/runtime/expression_cache.py +498 -0
- quantum/runtime/file_upload_service.py +326 -0
- quantum/runtime/function_registry.py +118 -0
- quantum/runtime/game_builder.py +166 -0
- quantum/runtime/game_code_generator.py +2371 -0
- quantum/runtime/game_templates.py +2006 -0
- quantum/runtime/godot_code_generator.py +4681 -0
- quantum/runtime/godot_templates.py +1449 -0
- quantum/runtime/job_executor.py +1599 -0
- quantum/runtime/knowledge_service.py +500 -0
- quantum/runtime/llm_cache.py +100 -0
- quantum/runtime/llm_providers.py +704 -0
- quantum/runtime/llm_service.py +287 -0
- quantum/runtime/logging_setup.py +140 -0
- quantum/runtime/message_broker.py +364 -0
- quantum/runtime/message_queue_service.py +571 -0
- quantum/runtime/param_validation.py +184 -0
- quantum/runtime/pypy_compat.py +315 -0
- quantum/runtime/python_bridge.py +698 -0
- quantum/runtime/query_validators.py +304 -0
- quantum/runtime/renderer.py +733 -0
- quantum/runtime/service_container.py +444 -0
- quantum/runtime/terminal_builder.py +76 -0
- quantum/runtime/terminal_code_generator.py +607 -0
- quantum/runtime/terminal_templates.py +243 -0
- quantum/runtime/testing_builder.py +77 -0
- quantum/runtime/testing_code_generator.py +833 -0
- quantum/runtime/testing_templates.py +85 -0
- quantum/runtime/ui_builder.py +188 -0
- quantum/runtime/ui_desktop_adapter.py +1730 -0
- quantum/runtime/ui_desktop_templates.py +307 -0
- quantum/runtime/ui_html_adapter.py +2691 -0
- quantum/runtime/ui_html_templates.py +2297 -0
- quantum/runtime/ui_mobile_adapter.py +1832 -0
- quantum/runtime/ui_mobile_templates.py +1003 -0
- quantum/runtime/ui_textual_adapter.py +1866 -0
- quantum/runtime/ui_textual_templates.py +45 -0
- quantum/runtime/ui_tokens.py +465 -0
- quantum/runtime/ui_validator.py +365 -0
- quantum/runtime/validators.py +256 -0
- quantum/runtime/web_server.py +1766 -0
- quantum/runtime/websocket_adapter.py +501 -0
- quantum/runtime/websocket_service.py +585 -0
- quantum/runtime/websocket_transport.py +289 -0
- quantum/runtime/wsgi.py +101 -0
- quantum/utils/__init__.py +1 -0
- quantum_framework-0.9.0.dist-info/METADATA +244 -0
- quantum_framework-0.9.0.dist-info/RECORD +262 -0
- quantum_framework-0.9.0.dist-info/WHEEL +5 -0
- quantum_framework-0.9.0.dist-info/entry_points.txt +2 -0
- quantum_framework-0.9.0.dist-info/licenses/LICENSE +21 -0
- 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)
|