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