opacity + animation fixes

This commit is contained in:
John McCardle 2026-02-03 12:18:21 -05:00
commit 045b625655
9 changed files with 845 additions and 151 deletions

View file

@ -284,6 +284,11 @@ bool UIArc::setProperty(const std::string& name, float value) {
markDirty(); markDirty();
return true; return true;
} }
else if (name == "opacity") {
opacity = std::clamp(value, 0.0f, 1.0f);
markDirty();
return true;
}
return false; return false;
} }
@ -342,6 +347,10 @@ bool UIArc::getProperty(const std::string& name, float& value) const {
value = origin.y; value = origin.y;
return true; return true;
} }
else if (name == "opacity") {
value = opacity;
return true;
}
return false; return false;
} }
@ -364,7 +373,7 @@ bool UIArc::getProperty(const std::string& name, sf::Vector2f& value) const {
bool UIArc::hasProperty(const std::string& name) const { bool UIArc::hasProperty(const std::string& name) const {
// Float properties // Float properties
if (name == "radius" || name == "start_angle" || name == "end_angle" || if (name == "radius" || name == "start_angle" || name == "end_angle" ||
name == "thickness" || name == "x" || name == "y" || name == "thickness" || name == "x" || name == "y" || name == "opacity" ||
name == "rotation" || name == "origin_x" || name == "origin_y") { name == "rotation" || name == "origin_x" || name == "origin_y") {
return true; return true;
} }

View file

@ -66,9 +66,10 @@ void UICaption::render(sf::Vector2f offset, sf::RenderTarget& target)
// Check visibility // Check visibility
if (!visible) return; if (!visible) return;
// Apply opacity // Apply opacity (multiply with fill_color alpha)
auto color = text.getFillColor(); auto color = text.getFillColor();
color.a = static_cast<sf::Uint8>(255 * opacity); sf::Uint8 original_alpha = color.a;
color.a = static_cast<sf::Uint8>(original_alpha * opacity);
text.setFillColor(color); text.setFillColor(color);
// Apply rotation and origin // Apply rotation and origin
@ -114,7 +115,7 @@ void UICaption::render(sf::Vector2f offset, sf::RenderTarget& target)
} }
// Restore original alpha // Restore original alpha
color.a = 255; color.a = original_alpha;
text.setFillColor(color); text.setFillColor(color);
} }
@ -604,6 +605,11 @@ bool UICaption::setProperty(const std::string& name, float value) {
markDirty(); // #144 - Content change markDirty(); // #144 - Content change
return true; return true;
} }
else if (name == "opacity") {
opacity = std::clamp(value, 0.0f, 1.0f);
markDirty(); // #144 - Visual change
return true;
}
else if (name == "fill_color.r") { else if (name == "fill_color.r") {
auto color = text.getFillColor(); auto color = text.getFillColor();
color.r = static_cast<sf::Uint8>(std::clamp(value, 0.0f, 255.0f)); color.r = static_cast<sf::Uint8>(std::clamp(value, 0.0f, 255.0f));
@ -730,6 +736,10 @@ bool UICaption::getProperty(const std::string& name, float& value) const {
value = text.getOutlineThickness(); value = text.getOutlineThickness();
return true; return true;
} }
else if (name == "opacity") {
value = opacity;
return true;
}
else if (name == "fill_color.r") { else if (name == "fill_color.r") {
value = text.getFillColor().r; value = text.getFillColor().r;
return true; return true;
@ -808,7 +818,7 @@ bool UICaption::getProperty(const std::string& name, std::string& value) const {
bool UICaption::hasProperty(const std::string& name) const { bool UICaption::hasProperty(const std::string& name) const {
// Float properties // Float properties
if (name == "x" || name == "y" || if (name == "x" || name == "y" ||
name == "font_size" || name == "size" || name == "outline" || name == "font_size" || name == "size" || name == "outline" || name == "opacity" ||
name == "fill_color.r" || name == "fill_color.g" || name == "fill_color.r" || name == "fill_color.g" ||
name == "fill_color.b" || name == "fill_color.a" || name == "fill_color.b" || name == "fill_color.a" ||
name == "outline_color.r" || name == "outline_color.g" || name == "outline_color.r" || name == "outline_color.g" ||

View file

@ -230,6 +230,10 @@ bool UICircle::setProperty(const std::string& name, float value) {
shape.setOrigin(radius + origin.x, radius + origin.y); shape.setOrigin(radius + origin.x, radius + origin.y);
markDirty(); markDirty();
return true; return true;
} else if (name == "opacity") {
opacity = std::clamp(value, 0.0f, 1.0f);
markDirty();
return true;
} }
return false; return false;
} }
@ -278,6 +282,9 @@ bool UICircle::getProperty(const std::string& name, float& value) const {
} else if (name == "origin_y") { } else if (name == "origin_y") {
value = origin.y; value = origin.y;
return true; return true;
} else if (name == "opacity") {
value = opacity;
return true;
} }
return false; return false;
} }
@ -303,7 +310,7 @@ bool UICircle::getProperty(const std::string& name, sf::Vector2f& value) const {
bool UICircle::hasProperty(const std::string& name) const { bool UICircle::hasProperty(const std::string& name) const {
// Float properties // Float properties
if (name == "radius" || name == "outline" || if (name == "radius" || name == "outline" || name == "opacity" ||
name == "x" || name == "y" || name == "x" || name == "y" ||
name == "rotation" || name == "origin_x" || name == "origin_y") { name == "rotation" || name == "origin_x" || name == "origin_y") {
return true; return true;

View file

@ -126,7 +126,15 @@ void UIFrame::render(sf::Vector2f offset, sf::RenderTarget& target)
// Check visibility // Check visibility
if (!visible) return; if (!visible) return;
// TODO: Apply opacity when SFML supports it on shapes // Apply opacity (multiply with fill/outline color alpha)
auto fill_color = box.getFillColor();
auto outline_color = box.getOutlineColor();
sf::Uint8 original_fill_alpha = fill_color.a;
sf::Uint8 original_outline_alpha = outline_color.a;
fill_color.a = static_cast<sf::Uint8>(original_fill_alpha * opacity);
outline_color.a = static_cast<sf::Uint8>(original_outline_alpha * opacity);
box.setFillColor(fill_color);
box.setOutlineColor(outline_color);
// #144: Use RenderTexture for clipping OR texture caching OR shaders (#106) // #144: Use RenderTexture for clipping OR texture caching OR shaders (#106)
// clip_children: requires texture for clipping effect (only when has children) // clip_children: requires texture for clipping effect (only when has children)
@ -194,6 +202,11 @@ void UIFrame::render(sf::Vector2f offset, sf::RenderTarget& target)
render_sprite.setOrigin(origin); render_sprite.setOrigin(origin);
render_sprite.setRotation(rotation); render_sprite.setRotation(rotation);
// Apply opacity to render_sprite
auto sprite_color = render_sprite.getColor();
sprite_color.a = static_cast<sf::Uint8>(255 * opacity);
render_sprite.setColor(sprite_color);
// #106: Apply shader if set // #106: Apply shader if set
if (shader && shader->shader) { if (shader && shader->shader) {
// Apply engine uniforms (time, resolution, mouse, texture) // Apply engine uniforms (time, resolution, mouse, texture)
@ -236,6 +249,12 @@ void UIFrame::render(sf::Vector2f offset, sf::RenderTarget& target)
drawable->render(offset + position, target); // Use `position` as source of truth drawable->render(offset + position, target); // Use `position` as source of truth
} }
} }
// Restore original colors
fill_color.a = original_fill_alpha;
outline_color.a = original_outline_alpha;
box.setFillColor(fill_color);
box.setOutlineColor(outline_color);
} }
PyObject* UIFrame::get_children(PyUIFrameObject* self, void* closure) PyObject* UIFrame::get_children(PyUIFrameObject* self, void* closure)
@ -836,6 +855,10 @@ bool UIFrame::setProperty(const std::string& name, float value) {
box.setOutlineThickness(value); box.setOutlineThickness(value);
markDirty(); markDirty();
return true; return true;
} else if (name == "opacity") {
opacity = std::clamp(value, 0.0f, 1.0f);
markDirty();
return true;
} else if (name == "fill_color.r") { } else if (name == "fill_color.r") {
auto color = box.getFillColor(); auto color = box.getFillColor();
color.r = std::clamp(static_cast<int>(value), 0, 255); color.r = std::clamp(static_cast<int>(value), 0, 255);
@ -960,6 +983,9 @@ bool UIFrame::getProperty(const std::string& name, float& value) const {
} else if (name == "outline") { } else if (name == "outline") {
value = box.getOutlineThickness(); value = box.getOutlineThickness();
return true; return true;
} else if (name == "opacity") {
value = opacity;
return true;
} else if (name == "fill_color.r") { } else if (name == "fill_color.r") {
value = box.getFillColor().r; value = box.getFillColor().r;
return true; return true;
@ -1029,7 +1055,7 @@ bool UIFrame::getProperty(const std::string& name, sf::Vector2f& value) const {
bool UIFrame::hasProperty(const std::string& name) const { bool UIFrame::hasProperty(const std::string& name) const {
// Float properties // Float properties
if (name == "x" || name == "y" || name == "w" || name == "h" || if (name == "x" || name == "y" || name == "w" || name == "h" ||
name == "outline" || name == "outline" || name == "opacity" ||
name == "fill_color.r" || name == "fill_color.g" || name == "fill_color.r" || name == "fill_color.g" ||
name == "fill_color.b" || name == "fill_color.a" || name == "fill_color.b" || name == "fill_color.a" ||
name == "outline_color.r" || name == "outline_color.g" || name == "outline_color.r" || name == "outline_color.g" ||

View file

@ -284,6 +284,11 @@ bool UILine::setProperty(const std::string& name, float value) {
markDirty(); markDirty();
return true; return true;
} }
else if (name == "opacity") {
opacity = std::clamp(value, 0.0f, 1.0f);
markDirty();
return true;
}
return false; return false;
} }
@ -354,6 +359,10 @@ bool UILine::getProperty(const std::string& name, float& value) const {
value = origin.y; value = origin.y;
return true; return true;
} }
else if (name == "opacity") {
value = opacity;
return true;
}
return false; return false;
} }
@ -379,7 +388,7 @@ bool UILine::getProperty(const std::string& name, sf::Vector2f& value) const {
bool UILine::hasProperty(const std::string& name) const { bool UILine::hasProperty(const std::string& name) const {
// Float properties // Float properties
if (name == "thickness" || name == "x" || name == "y" || if (name == "thickness" || name == "x" || name == "y" || name == "opacity" ||
name == "start_x" || name == "start_y" || name == "start_x" || name == "start_y" ||
name == "end_x" || name == "end_y" || name == "end_x" || name == "end_y" ||
name == "rotation" || name == "origin_x" || name == "origin_y") { name == "rotation" || name == "origin_x" || name == "origin_y") {

View file

@ -109,9 +109,10 @@ void UISprite::render(sf::Vector2f offset, sf::RenderTarget& target)
// Check visibility // Check visibility
if (!visible) return; if (!visible) return;
// Apply opacity // Apply opacity (multiply with sprite color alpha)
auto color = sprite.getColor(); auto color = sprite.getColor();
color.a = static_cast<sf::Uint8>(255 * opacity); sf::Uint8 original_alpha = color.a;
color.a = static_cast<sf::Uint8>(original_alpha * opacity);
sprite.setColor(color); sprite.setColor(color);
// Apply rotation and origin // Apply rotation and origin
@ -157,7 +158,7 @@ void UISprite::render(sf::Vector2f offset, sf::RenderTarget& target)
} }
// Restore original alpha // Restore original alpha
color.a = 255; color.a = original_alpha;
sprite.setColor(color); sprite.setColor(color);
} }
@ -653,6 +654,11 @@ bool UISprite::setProperty(const std::string& name, float value) {
markDirty(); // #144 - Content change markDirty(); // #144 - Content change
return true; return true;
} }
else if (name == "opacity") {
opacity = std::clamp(value, 0.0f, 1.0f);
markDirty(); // #144 - Visual change
return true;
}
else if (name == "z_index") { else if (name == "z_index") {
z_index = static_cast<int>(value); z_index = static_cast<int>(value);
markDirty(); // #144 - Z-order change affects parent markDirty(); // #144 - Z-order change affects parent
@ -718,6 +724,10 @@ bool UISprite::getProperty(const std::string& name, float& value) const {
value = sprite.getScale().y; value = sprite.getScale().y;
return true; return true;
} }
else if (name == "opacity") {
value = opacity;
return true;
}
else if (name == "z_index") { else if (name == "z_index") {
value = static_cast<float>(z_index); value = static_cast<float>(z_index);
return true; return true;
@ -757,7 +767,7 @@ bool UISprite::hasProperty(const std::string& name) const {
// Float properties // Float properties
if (name == "x" || name == "y" || if (name == "x" || name == "y" ||
name == "scale" || name == "scale_x" || name == "scale_y" || name == "scale" || name == "scale_x" || name == "scale_y" ||
name == "z_index" || name == "opacity" || name == "z_index" ||
name == "rotation" || name == "origin_x" || name == "origin_y") { name == "rotation" || name == "origin_x" || name == "origin_y") {
return true; return true;
} }

View file

@ -53,6 +53,16 @@ int run_python_string(const char* code) {
// Returns a pointer to a static buffer (caller should copy immediately) // Returns a pointer to a static buffer (caller should copy immediately)
static std::string python_output_buffer; static std::string python_output_buffer;
// Helper to safely delete a key from a dict without leaving error state
static void safe_dict_del(PyObject* dict, const char* key) {
PyObject* py_key = PyUnicode_FromString(key);
if (py_key && PyDict_Contains(dict, py_key)) {
PyDict_DelItem(dict, py_key);
}
Py_XDECREF(py_key);
PyErr_Clear(); // Clear any error from Contains or Del
}
EMSCRIPTEN_KEEPALIVE EMSCRIPTEN_KEEPALIVE
const char* run_python_string_with_output(const char* code) { const char* run_python_string_with_output(const char* code) {
if (!Py_IsInitialized()) { if (!Py_IsInitialized()) {
@ -60,6 +70,12 @@ const char* run_python_string_with_output(const char* code) {
return python_output_buffer.c_str(); return python_output_buffer.c_str();
} }
// CRITICAL: Clear any lingering error state before execution
// This prevents "clogged" interpreter state from previous errors
if (PyErr_Occurred()) {
PyErr_Clear();
}
// Redirect stdout and stderr to a StringIO, run code, capture output // Redirect stdout and stderr to a StringIO, run code, capture output
// Also capture repr of last expression (like Python REPL) // Also capture repr of last expression (like Python REPL)
const char* capture_code = R"( const char* capture_code = R"(
@ -113,7 +129,37 @@ elif _mcrf_last_repr:
Py_DECREF(py_code); Py_DECREF(py_code);
// Run the capture code // Run the capture code
PyRun_SimpleString(capture_code); int result = PyRun_SimpleString(capture_code);
// Check if capture code itself failed (e.g., internal error in REPL wrapper)
if (result != 0 || PyErr_Occurred()) {
// Capture the Python error before clearing
PyObject *ptype, *pvalue, *ptraceback;
PyErr_Fetch(&ptype, &pvalue, &ptraceback);
python_output_buffer = "Internal REPL Error: ";
if (pvalue) {
PyObject* str = PyObject_Str(pvalue);
if (str) {
const char* err_str = PyUnicode_AsUTF8(str);
if (err_str) {
python_output_buffer += err_str;
}
Py_DECREF(str);
}
} else {
python_output_buffer += "Unknown error in capture code";
}
Py_XDECREF(ptype);
Py_XDECREF(pvalue);
Py_XDECREF(ptraceback);
PyErr_Clear(); // Ensure clean state for next execution
// Still clean up what we can
safe_dict_del(main_dict, "_mcrf_user_code");
return python_output_buffer.c_str();
}
// Get the captured output // Get the captured output
PyObject* output = PyDict_GetItemString(main_dict, "_mcrf_captured_output"); PyObject* output = PyDict_GetItemString(main_dict, "_mcrf_captured_output");
@ -124,14 +170,22 @@ elif _mcrf_last_repr:
python_output_buffer = ""; python_output_buffer = "";
} }
// Clean up temporary variables // Clean up ALL temporary variables (including previously missed ones)
PyDict_DelItemString(main_dict, "_mcrf_user_code"); safe_dict_del(main_dict, "_mcrf_user_code");
PyDict_DelItemString(main_dict, "_mcrf_stdout_capture"); safe_dict_del(main_dict, "_mcrf_stdout_capture");
PyDict_DelItemString(main_dict, "_mcrf_stderr_capture"); safe_dict_del(main_dict, "_mcrf_stderr_capture");
PyDict_DelItemString(main_dict, "_mcrf_old_stdout"); safe_dict_del(main_dict, "_mcrf_old_stdout");
PyDict_DelItemString(main_dict, "_mcrf_old_stderr"); safe_dict_del(main_dict, "_mcrf_old_stderr");
PyDict_DelItemString(main_dict, "_mcrf_exec_error"); safe_dict_del(main_dict, "_mcrf_exec_error");
PyDict_DelItemString(main_dict, "_mcrf_captured_output"); safe_dict_del(main_dict, "_mcrf_captured_output");
safe_dict_del(main_dict, "_mcrf_last_repr"); // Previously leaked
safe_dict_del(main_dict, "_mcrf_result"); // Previously leaked
safe_dict_del(main_dict, "_mcrf_code_obj"); // Previously leaked
// Final safety check - clear any residual error state
if (PyErr_Occurred()) {
PyErr_Clear();
}
return python_output_buffer.c_str(); return python_output_buffer.c_str();
} }
@ -143,6 +197,11 @@ int reset_python_environment() {
return -1; return -1;
} }
// Clear any lingering error state first
if (PyErr_Occurred()) {
PyErr_Clear();
}
// Clear all scenes and reload game.py // Clear all scenes and reload game.py
const char* reset_code = R"( const char* reset_code = R"(
import mcrfpy import mcrfpy
@ -161,7 +220,74 @@ except Exception as e:
print(f"Reset error: {e}") print(f"Reset error: {e}")
)"; )";
return PyRun_SimpleString(reset_code); int result = PyRun_SimpleString(reset_code);
// Clear any error state from reset
if (PyErr_Occurred()) {
PyErr_Clear();
}
return result;
}
// =============================================================================
// Interpreter health check functions (for debugging)
// =============================================================================
static std::string python_state_buffer;
// Check the current state of the Python interpreter
// Returns: "OK", "NOT_INITIALIZED", or "ERROR_SET: <message>"
EMSCRIPTEN_KEEPALIVE
const char* get_python_state() {
if (!Py_IsInitialized()) {
return "NOT_INITIALIZED";
}
if (PyErr_Occurred()) {
PyObject *ptype, *pvalue, *ptraceback;
PyErr_Fetch(&ptype, &pvalue, &ptraceback);
python_state_buffer = "ERROR_SET: ";
if (pvalue) {
PyObject* str = PyObject_Str(pvalue);
if (str) {
const char* err_str = PyUnicode_AsUTF8(str);
if (err_str) {
python_state_buffer += err_str;
}
Py_DECREF(str);
}
} else {
python_state_buffer += "Unknown error";
}
// Restore the error so caller can decide what to do
PyErr_Restore(ptype, pvalue, ptraceback);
return python_state_buffer.c_str();
}
return "OK";
}
// Clear any pending Python error state
EMSCRIPTEN_KEEPALIVE
void clear_python_error() {
if (Py_IsInitialized() && PyErr_Occurred()) {
PyErr_Clear();
}
}
// Get a count of items in Python's global namespace (for debugging memory leaks)
EMSCRIPTEN_KEEPALIVE
int get_python_globals_count() {
if (!Py_IsInitialized()) {
return -1;
}
PyObject* main_module = PyImport_AddModule("__main__");
PyObject* main_dict = PyModule_GetDict(main_module);
return (int)PyDict_Size(main_dict);
} }
} // extern "C" } // extern "C"

View file

@ -13,10 +13,11 @@ class PlaygroundScene(mcrfpy.Scene):
for a in mcrfpy.animations: for a in mcrfpy.animations:
a.stop() a.stop()
for s in mcrfpy.scenes: for s in mcrfpy.scenes:
if s is self: continue
s.unregister() s.unregister()
self.activate()
while self.children: while self.children:
self.children.pop() self.children.pop()
self.activate()
scene = PlaygroundScene() scene = PlaygroundScene()
scene.activate() scene.activate()

View file

@ -4,6 +4,13 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>McRogueFace - WebGL</title> <title>McRogueFace - WebGL</title>
<!-- LZ-String for URL compression -->
<script src="https://unpkg.com/lz-string@1.5.0/libs/lz-string.min.js"></script>
<!-- CodeMirror from CDN -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/dracula.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/python/python.min.js"></script>
<style> <style>
* { * {
box-sizing: border-box; box-sizing: border-box;
@ -25,11 +32,26 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 10px; margin-bottom: 10px;
flex-wrap: wrap;
gap: 10px;
}
.header-left {
display: flex;
align-items: center;
gap: 15px;
} }
h1 { h1 {
margin: 0; margin: 0;
color: #e94560; color: #e94560;
} }
h1 a {
color: inherit;
text-decoration: none;
}
.tagline {
color: #666;
font-size: 14px;
}
#status { #status {
color: #888; color: #888;
} }
@ -43,24 +65,21 @@
min-width: 300px; min-width: 300px;
} }
#canvas { #canvas {
/* border: 2px solid #e94560; -- disabled per Emscripten docs, causes alignment issues */
background: #000; background: #000;
display: block; display: block;
max-width: 100%; max-width: 100%;
height: auto; height: auto;
/* Crisp pixel rendering - no smoothing/interpolation */
image-rendering: pixelated; image-rendering: pixelated;
image-rendering: crisp-edges; image-rendering: crisp-edges;
-ms-interpolation-mode: nearest-neighbor; -ms-interpolation-mode: nearest-neighbor;
/* Ensure canvas can receive focus */
outline: none; outline: none;
} }
#canvas:focus { #canvas:focus {
box-shadow: 0 0 10px rgba(78, 204, 163, 0.5); box-shadow: 0 0 10px rgba(78, 204, 163, 0.5);
} }
.repl-panel { .repl-panel {
width: 400px; width: 450px;
min-width: 300px; min-width: 350px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #16213e; background: #16213e;
@ -74,15 +93,35 @@
padding: 10px 15px; padding: 10px 15px;
background: #0f3460; background: #0f3460;
border-bottom: 1px solid #e94560; border-bottom: 1px solid #e94560;
flex-wrap: wrap;
gap: 8px;
}
.repl-title {
display: flex;
align-items: center;
gap: 8px;
} }
.repl-header h3 { .repl-header h3 {
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
color: #e94560; color: #e94560;
} }
/* Interpreter status indicator */
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
transition: background-color 0.3s;
}
.status-ok { background-color: #4ecca3; }
.status-error { background-color: #e94560; }
.status-busy { background-color: #f39c12; }
.repl-buttons { .repl-buttons {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center;
flex-wrap: wrap;
} }
.repl-buttons button { .repl-buttons button {
padding: 6px 12px; padding: 6px 12px;
@ -100,6 +139,13 @@
#runBtn:hover { #runBtn:hover {
background: #3db892; background: #3db892;
} }
#shareBtn {
background: #667eea;
color: white;
}
#shareBtn:hover {
background: #5a6fd6;
}
#resetBtn { #resetBtn {
background: #e94560; background: #e94560;
color: white; color: white;
@ -118,28 +164,27 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
#codeEditor { /* CodeMirror container */
width: 100%; .editor-container {
height: 250px; border-bottom: 1px solid #333;
padding: 12px;
border: none;
background: #0f0f23;
color: #4ecca3;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
resize: vertical;
outline: none;
} }
#codeEditor::placeholder { .CodeMirror {
color: #555; height: 430px;
font-size: 13px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
} }
.repl-output-header { .repl-output-header {
padding: 8px 15px; padding: 8px 15px;
background: #0f3460; background: #0f3460;
font-size: 12px; font-size: 12px;
color: #888; color: #888;
border-top: 1px solid #333; display: flex;
justify-content: space-between;
align-items: center;
}
.shortcut-hint {
font-size: 11px;
color: #666;
} }
#replOutput { #replOutput {
flex: 1; flex: 1;
@ -159,6 +204,12 @@
#replOutput .success { #replOutput .success {
color: #4ecca3; color: #4ecca3;
} }
#replOutput .warning {
color: #f39c12;
}
#replOutput .input {
color: #888;
}
#output { #output {
margin-top: 20px; margin-top: 20px;
width: 100%; width: 100%;
@ -184,16 +235,6 @@
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
.toggle-repl {
display: none;
margin-top: 10px;
padding: 8px 16px;
background: #0f3460;
border: 1px solid #e94560;
color: #e94560;
border-radius: 4px;
cursor: pointer;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.main-content { .main-content {
flex-direction: column; flex-direction: column;
@ -202,18 +243,96 @@
width: 100%; width: 100%;
} }
} }
/* Keyboard shortcut hint */ /* Source indicator for gists */
.shortcut-hint { .source-indicator {
font-size: 11px; padding: 5px 10px;
color: #666; background: #0f3460;
margin-left: 8px; border-radius: 4px;
font-size: 12px;
color: #4ecca3;
}
.source-indicator.hidden {
display: none;
}
.source-indicator a {
color: #667eea;
}
/* Share modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
}
.modal-overlay.hidden {
display: none;
}
.modal {
background: #16213e;
padding: 25px;
border-radius: 12px;
max-width: 500px;
width: 90%;
border: 2px solid #e94560;
}
.modal h2 {
margin: 0 0 15px 0;
color: #e94560;
}
.modal p {
color: #888;
margin: 10px 0;
font-size: 14px;
}
.modal input[type="text"] {
width: 100%;
padding: 12px;
background: #0f0f23;
border: 1px solid #333;
color: #4ecca3;
font-family: monospace;
font-size: 13px;
border-radius: 4px;
margin: 10px 0;
}
.modal-buttons {
display: flex;
gap: 10px;
margin-top: 15px;
}
.modal-buttons button {
flex: 1;
padding: 10px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.modal .copy-btn {
background: #4ecca3;
color: #1a1a2e;
}
.modal .close-btn {
background: #666;
color: white;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<h1>McRogueFace</h1> <div class="header-left">
<h1><a href="/">McRogueFace</a></h1>
<span class="tagline">Playground</span>
<span id="sourceIndicator" class="source-indicator hidden"></span>
</div>
<div id="status">Downloading...</div> <div id="status">Downloading...</div>
</header> </header>
@ -226,23 +345,24 @@
<div class="repl-panel" id="replPanel"> <div class="repl-panel" id="replPanel">
<div class="repl-header"> <div class="repl-header">
<div class="repl-title">
<span id="interpreterStatus" class="status-indicator status-busy" title="Initializing..."></span>
<h3>Python REPL</h3> <h3>Python REPL</h3>
</div>
<div class="repl-buttons"> <div class="repl-buttons">
<button id="runBtn" disabled>Run</button> <button id="runBtn" disabled>Run</button>
<button id="shareBtn">Share</button>
<button id="resetBtn" disabled>Reset</button> <button id="resetBtn" disabled>Reset</button>
<button id="clearBtn">Clear</button> <button id="clearBtn">Clear</button>
<span class="shortcut-hint">Ctrl+Enter to run</span>
</div> </div>
</div> </div>
<textarea id="codeEditor" placeholder="Write Python code here... <div class="editor-container">
<textarea id="codeEditor"></textarea>
import mcrfpy </div>
scene = mcrfpy.Scene('test') <div class="repl-output-header">
frame = mcrfpy.Frame(100, 100, 200, 150) <span>Output</span>
scene.ui.append(frame) <span class="shortcut-hint">Ctrl+Enter to run | Ctrl+Up/Down for history</span>
mcrfpy.setScene('test') </div>
"></textarea>
<div class="repl-output-header">Output</div>
<div id="replOutput"></div> <div id="replOutput"></div>
</div> </div>
</div> </div>
@ -250,21 +370,312 @@ mcrfpy.setScene('test')
<div id="output"></div> <div id="output"></div>
</div> </div>
<!-- Share Modal -->
<div id="shareModal" class="modal-overlay hidden">
<div class="modal">
<h2>Share Your Code</h2>
<p>Copy this URL to share your playground:</p>
<input type="text" id="shareUrl" readonly onclick="this.select()">
<p id="shareSize" style="font-size: 12px;"></p>
<div class="modal-buttons">
<button class="copy-btn" id="copyBtn">Copy to Clipboard</button>
<button class="close-btn" id="closeModalBtn">Close</button>
</div>
</div>
</div>
<script type='text/javascript'> <script type='text/javascript'>
// ===========================================
// CONFIGURATION
// ===========================================
const CONFIG = {
playgroundUrl: window.location.origin,
shortUrl: window.location.origin,
playgroundPath: window.location.pathname.replace(/\/[^\/]*$/, ''),
maxCodeSize: 16 * 1024,
githubApiBase: 'https://api.github.com'
};
// ===========================================
// URL Fragment Handling (Share & Gist support)
// ===========================================
async function parseUrlFragment() {
const hash = window.location.hash.slice(1);
if (!hash) return null;
const params = new URLSearchParams(hash);
// Handle #src=<lz-compressed>
if (params.has('src')) {
try {
const compressed = params.get('src');
const code = LZString.decompressFromEncodedURIComponent(compressed);
if (code) {
return { type: 'inline', code: code };
}
} catch (e) {
console.error('Failed to decompress code:', e);
}
}
// Handle #gist=user/id or #gist=id
if (params.has('gist')) {
const gistParam = params.get('gist');
const file = params.get('file');
return { type: 'gist', gistId: gistParam, file: file };
}
return null;
}
async function loadGist(gistId, preferredFile) {
const id = gistId.includes('/') ? gistId.split('/').pop() : gistId;
try {
const resp = await fetch(`${CONFIG.githubApiBase}/gists/${id}`);
if (!resp.ok) {
throw new Error(`Gist not found (${resp.status})`);
}
const data = await resp.json();
const files = Object.values(data.files);
let targetFile = null;
if (preferredFile) {
targetFile = files.find(f => f.filename === preferredFile);
}
if (!targetFile) {
targetFile = files.find(f => f.filename.endsWith('.py'));
}
if (!targetFile) {
targetFile = files[0];
}
return {
code: targetFile.content,
filename: targetFile.filename,
gistUrl: data.html_url,
owner: data.owner?.login || 'anonymous',
description: data.description
};
} catch (e) {
console.error('Failed to load gist:', e);
throw e;
}
}
function generateShareUrl(code) {
const compressed = LZString.compressToEncodedURIComponent(code);
const path = CONFIG.playgroundPath || '';
const url = `${CONFIG.shortUrl}${path}/#src=${compressed}`;
return {
url: url,
originalSize: code.length,
compressedSize: compressed.length,
ratio: ((compressed.length / code.length) * 100).toFixed(1)
};
}
// ===========================================
// DOM Elements
// ===========================================
var statusElement = document.getElementById('status'); var statusElement = document.getElementById('status');
var spinnerElement = document.getElementById('spinner'); var spinnerElement = document.getElementById('spinner');
var outputElement = document.getElementById('output'); var outputElement = document.getElementById('output');
var canvasElement = document.getElementById('canvas'); var canvasElement = document.getElementById('canvas');
var codeEditor = document.getElementById('codeEditor');
var replOutput = document.getElementById('replOutput'); var replOutput = document.getElementById('replOutput');
var runBtn = document.getElementById('runBtn'); var runBtn = document.getElementById('runBtn');
var shareBtn = document.getElementById('shareBtn');
var resetBtn = document.getElementById('resetBtn'); var resetBtn = document.getElementById('resetBtn');
var clearBtn = document.getElementById('clearBtn'); var clearBtn = document.getElementById('clearBtn');
var interpreterStatus = document.getElementById('interpreterStatus');
var sourceIndicator = document.getElementById('sourceIndicator');
var shareModal = document.getElementById('shareModal');
var shareUrlInput = document.getElementById('shareUrl');
var shareSizeEl = document.getElementById('shareSize');
var copyBtn = document.getElementById('copyBtn');
var closeModalBtn = document.getElementById('closeModalBtn');
// Initialize CodeMirror
var editor = CodeMirror.fromTextArea(document.getElementById('codeEditor'), {
mode: 'python',
theme: 'dracula',
lineNumbers: true,
indentUnit: 4,
tabSize: 4,
indentWithTabs: false,
electricChars: true,
matchBrackets: true
});
// Default content
var defaultCode = `import mcrfpy
# the default "playground" scene
scene = mcrfpy.current_scene
# Frame:
frame = mcrfpy.Frame((10, 10), (50, 50), fill_color=(30,30,80))
scene.children.append(frame)
# Caption:
caption = mcrfpy.Caption((10,60), text="Hello\nMcRogueFace!", font_size=32) # uses default font
scene.children.append(caption)
# Sprite:
sprite = mcrfpy.Sprite((10, 150), sprite_index=84, scale=4.0) # uses default sprite sheet
scene.children.append(sprite)
# Grid:
grid = mcrfpy.Grid((250,10), (320,320), grid_size=(10,10), zoom=2.0)
scene.children.append(grid)
# place entities on grid squares
mcrfpy.Entity((4,5), sprite_index=85, grid=grid) # uses default sprite sheet
mcrfpy.Entity((5,9), sprite_index=87, grid=grid)
mcrfpy.Entity((3,7), sprite_index=89, grid=grid)
# fill with some dirt
import random
for x in range(10):
for y in range(10):
# mostly 0 for plain dirt, with two variations
grid[x,y].tilesprite = random.choice([0, 0, 0, 0, 0, 0, 12, 24])
# make the wizard sprite clickable
def poke(position, mousebtn, inputstate):
if inputstate != mcrfpy.InputState.PRESSED:
return
say = random.choice(["oof", "ouch", "uff"])
new_txt = mcrfpy.Caption(position, text=say)
scene.children.append(new_txt)
done = lambda *args: scene.children.remove(new_txt)
new_txt.animate("y", # property
-100, # target value
10.0 + random.randint(-4, 2), # duration
callback=done # called on completion
)
new_txt.animate("x", position.x + random.randint(-70, +270), 5.5)
new_txt.animate("fill_color.a", 0, 5.5)
sprite.on_click = poke
# make the wizard sprite moveable
def keypress(key, inputstate):
actions = { mcrfpy.Key.W: mcrfpy.Vector(0, -10),
mcrfpy.Key.A: mcrfpy.Vector(-10, 0),
mcrfpy.Key.S: mcrfpy.Vector(0, 10),
mcrfpy.Key.D: mcrfpy.Vector(10, 0) }
if inputstate != mcrfpy.InputState.PRESSED:
return
if key in actions:
sprite.pos += actions[key]
scene.on_key = keypress
print(mcrfpy.current_scene.children)
# Press F3 for stats
# create a new scene and switch to it:
#new_scene = mcrfpy.Scene("test")
#new_scene.activate()
`;
editor.setValue(defaultCode);
// Stop keyboard events from bubbling to SDL (bubble phase)
var cmElement = editor.getWrapperElement();
cmElement.addEventListener('keydown', function(e) {
e.stopPropagation();
// Handle Ctrl+Enter for running code
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
if (!runBtn.disabled) {
runCode();
}
}
// Handle Ctrl+Up/Down for history
if (e.ctrlKey && e.key === 'ArrowUp') {
e.preventDefault();
navigateHistory(-1);
}
if (e.ctrlKey && e.key === 'ArrowDown') {
e.preventDefault();
navigateHistory(1);
}
}, false);
cmElement.addEventListener('keyup', function(e) {
e.stopPropagation();
}, false);
cmElement.addEventListener('keypress', function(e) {
e.stopPropagation();
}, false);
// Execution history
var cmdHistory = [];
var historyIndex = -1;
var currentInput = '';
function navigateHistory(direction) {
if (cmdHistory.length === 0) return;
if (historyIndex === -1) {
currentInput = editor.getValue();
}
historyIndex += direction;
if (historyIndex < -1) {
historyIndex = -1;
} else if (historyIndex >= cmdHistory.length) {
historyIndex = cmdHistory.length - 1;
}
if (historyIndex === -1) {
editor.setValue(currentInput);
} else {
editor.setValue(cmdHistory[cmdHistory.length - 1 - historyIndex]);
}
editor.setCursor(editor.lineCount(), 0);
}
// Pre-set canvas size // Pre-set canvas size
canvasElement.width = 1024; canvasElement.width = 1024;
canvasElement.height = 768; canvasElement.height = 768;
// ===========================================
// Initialization (load from URL fragment)
// ===========================================
async function init() {
const source = await parseUrlFragment();
if (source) {
if (source.type === 'inline') {
editor.setValue(source.code);
sourceIndicator.textContent = 'Loaded from shared URL';
sourceIndicator.classList.remove('hidden');
} else if (source.type === 'gist') {
editor.setValue('# Loading gist...');
try {
const gist = await loadGist(source.gistId, source.file);
editor.setValue(gist.code);
sourceIndicator.innerHTML = `From: <a href="${gist.gistUrl}" target="_blank">${gist.owner}/${gist.filename}</a>`;
sourceIndicator.classList.remove('hidden');
} catch (e) {
editor.setValue(`# Failed to load gist: ${e.message}\n# Check the console for details.`);
}
}
}
}
// ===========================================
// Emscripten Module
// ===========================================
var Module = { var Module = {
print: (function() { print: (function() {
return function(text) { return function(text) {
@ -310,7 +721,7 @@ mcrfpy.setScene('test')
// Make FS available globally for console access // Make FS available globally for console access
window.FS = Module.FS; window.FS = Module.FS;
// Create convenient Python execution functions // Create Python execution functions
window.runPython = function(code) { window.runPython = function(code) {
return Module.ccall('run_python_string_with_output', 'string', ['string'], [code]); return Module.ccall('run_python_string_with_output', 'string', ['string'], [code]);
}; };
@ -318,12 +729,24 @@ mcrfpy.setScene('test')
return Module.ccall('reset_python_environment', 'number', [], []); return Module.ccall('reset_python_environment', 'number', [], []);
}; };
replOutput.innerHTML = '<span class="success">Python REPL ready. Enter code and click Run (or Ctrl+Enter).</span>\n'; // Interpreter health check functions
window.getPythonState = function() {
return Module.ccall('get_python_state', 'string', [], []);
};
window.clearPythonError = function() {
Module.ccall('clear_python_error', null, [], []);
};
window.getPythonGlobalsCount = function() {
return Module.ccall('get_python_globals_count', 'number', [], []);
};
updateInterpreterStatus();
appendToOutput('Python REPL ready. Enter code and click Run (or Ctrl+Enter).', 'success');
appendToOutput('Tip: Use Ctrl+Up/Down to navigate command history.', 'input');
// Focus canvas after a short delay to ensure SDL is ready
setTimeout(function() { setTimeout(function() {
canvasElement.focus(); canvasElement.focus();
// Trigger a synthetic resize to ensure SDL is properly initialized
window.dispatchEvent(new Event('resize')); window.dispatchEvent(new Event('resize'));
}, 100); }, 100);
} }
@ -336,7 +759,6 @@ mcrfpy.setScene('test')
spinnerElement.style.display = 'none'; spinnerElement.style.display = 'none';
}; };
// Stub for resolveGlobalSymbol
if (typeof resolveGlobalSymbol === 'undefined') { if (typeof resolveGlobalSymbol === 'undefined') {
window.resolveGlobalSymbol = function(name, direct) { window.resolveGlobalSymbol = function(name, direct) {
return { return {
@ -346,114 +768,188 @@ mcrfpy.setScene('test')
}; };
} }
// Prevent browser zoom on canvas // Click on canvas to focus it
document.addEventListener('wheel', function(e) {
if (e.ctrlKey) {
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
}, { passive: false, capture: true });
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && (e.key === '+' || e.key === '-' || e.key === '=' || e.key === '_')) {
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
}, { capture: true });
// Click on canvas to focus it (for keyboard input)
canvasElement.addEventListener('click', function() { canvasElement.addEventListener('click', function() {
canvasElement.focus(); canvasElement.focus();
}); });
// Also handle mousedown to ensure focus happens before SDL processes the click
canvasElement.addEventListener('mousedown', function() { canvasElement.addEventListener('mousedown', function() {
if (document.activeElement !== canvasElement) { if (document.activeElement !== canvasElement) {
canvasElement.focus(); canvasElement.focus();
} }
}); });
// REPL functionality // ===========================================
// Interpreter Status
// ===========================================
function updateInterpreterStatus() {
if (!window.getPythonState) {
interpreterStatus.className = 'status-indicator status-busy';
interpreterStatus.title = 'Initializing...';
return;
}
var state = window.getPythonState();
if (state === 'OK') {
interpreterStatus.className = 'status-indicator status-ok';
interpreterStatus.title = 'Interpreter ready';
} else if (state === 'NOT_INITIALIZED') {
interpreterStatus.className = 'status-indicator status-busy';
interpreterStatus.title = 'Python not initialized';
} else {
interpreterStatus.className = 'status-indicator status-error';
interpreterStatus.title = state;
}
}
function appendToOutput(text, className) {
var span = document.createElement('span');
span.className = className || '';
span.textContent = text + '\n';
replOutput.appendChild(span);
replOutput.scrollTop = replOutput.scrollHeight;
}
// ===========================================
// REPL Functionality
// ===========================================
function runCode() { function runCode() {
var code = codeEditor.value; var code = editor.getValue();
if (!code.trim()) return; if (!code.trim()) return;
replOutput.innerHTML += '<span style="color:#666">>>> </span><span style="color:#888">' + escapeHtml(code.split('\n')[0]) + (code.includes('\n') ? '...' : '') + '</span>\n'; // Add to history
if (cmdHistory.length === 0 || cmdHistory[cmdHistory.length - 1] !== code) {
cmdHistory.push(code);
}
historyIndex = -1;
var inputDisplay = '>>> ' + code.split('\n')[0] + (code.includes('\n') ? '...' : '');
appendToOutput(inputDisplay, 'input');
// Check interpreter state and auto-recover if needed
var state = window.getPythonState();
if (state !== 'OK' && state !== 'NOT_INITIALIZED') {
appendToOutput('Warning: Clearing previous error state...', 'warning');
window.clearPythonError();
updateInterpreterStatus();
}
try { try {
// Reset environment before running for idempotent execution
window.runPython('_reset()');
var result = window.runPython(code); var result = window.runPython(code);
updateInterpreterStatus();
if (result) { if (result) {
// Check if result contains error indicators if (result.includes('Traceback') ||
if (result.includes('Traceback') || result.includes('Error:')) { result.includes('Error:') ||
replOutput.innerHTML += '<span class="error">' + escapeHtml(result) + '</span>\n'; result.includes('Error\n') ||
result.startsWith('Internal REPL Error:')) {
appendToOutput(result, 'error');
} else { } else {
// Show result (could be print output or repr of expression) appendToOutput(result, 'success');
replOutput.innerHTML += '<span class="success">' + escapeHtml(result) + '</span>\n';
} }
} }
} catch (e) { } catch (e) {
replOutput.innerHTML += '<span class="error">JavaScript Error: ' + escapeHtml(e.toString()) + '</span>\n'; appendToOutput('JavaScript Error: ' + e.toString(), 'error');
updateInterpreterStatus();
} }
replOutput.scrollTop = replOutput.scrollHeight;
} }
function resetEnvironment() { function resetEnvironment() {
replOutput.innerHTML += '<span style="color:#888">>>> Resetting environment...</span>\n'; appendToOutput('>>> Resetting environment...', 'input');
if (window.clearPythonError) {
window.clearPythonError();
}
try { try {
window.resetGame(); window.resetGame();
replOutput.innerHTML += '<span class="success">Environment reset.</span>\n'; updateInterpreterStatus();
appendToOutput('Environment reset.', 'success');
} catch (e) { } catch (e) {
replOutput.innerHTML += '<span class="error">Reset error: ' + escapeHtml(e.toString()) + '</span>\n'; appendToOutput('Reset error: ' + e.toString(), 'error');
updateInterpreterStatus();
} }
replOutput.scrollTop = replOutput.scrollHeight;
} }
function clearOutput() { function clearOutput() {
replOutput.innerHTML = ''; replOutput.innerHTML = '';
} }
function escapeHtml(text) { // ===========================================
var div = document.createElement('div'); // Share Functionality
div.textContent = text; // ===========================================
return div.innerHTML;
function showShareModal() {
const code = editor.getValue();
if (code.length > CONFIG.maxCodeSize) {
alert(`Code is too large (${(code.length / 1024).toFixed(1)} KB). Maximum is ${CONFIG.maxCodeSize / 1024} KB.\n\nConsider using a GitHub Gist for larger code.`);
return;
} }
// Button handlers const result = generateShareUrl(code);
shareUrlInput.value = result.url;
shareSizeEl.textContent = `${result.originalSize} bytes -> ${result.compressedSize} chars (${result.ratio}% of original)`;
if (result.url.length > 2000) {
shareSizeEl.textContent += ' Warning: URL is long - may not work in all browsers';
}
shareModal.classList.remove('hidden');
}
copyBtn.addEventListener('click', function() {
shareUrlInput.select();
navigator.clipboard.writeText(shareUrlInput.value).then(function() {
copyBtn.textContent = 'Copied!';
setTimeout(function() {
copyBtn.textContent = 'Copy to Clipboard';
}, 2000);
}).catch(function() {
document.execCommand('copy');
copyBtn.textContent = 'Copied!';
setTimeout(function() {
copyBtn.textContent = 'Copy to Clipboard';
}, 2000);
});
});
closeModalBtn.addEventListener('click', function() {
shareModal.classList.add('hidden');
});
shareModal.addEventListener('click', function(e) {
if (e.target === shareModal) {
shareModal.classList.add('hidden');
}
});
// Escape closes modal
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !shareModal.classList.contains('hidden')) {
shareModal.classList.add('hidden');
}
});
// ===========================================
// Button Handlers
// ===========================================
runBtn.addEventListener('click', runCode); runBtn.addEventListener('click', runCode);
shareBtn.addEventListener('click', showShareModal);
resetBtn.addEventListener('click', resetEnvironment); resetBtn.addEventListener('click', resetEnvironment);
clearBtn.addEventListener('click', clearOutput); clearBtn.addEventListener('click', clearOutput);
// Keyboard shortcut: Ctrl+Enter to run // ===========================================
// IMPORTANT: Stop propagation to prevent SDL from consuming keystrokes // Initialize
codeEditor.addEventListener('keydown', function(e) { // ===========================================
e.stopPropagation(); // Prevent SDL from receiving this event
if (e.ctrlKey && e.key === 'Enter') { init();
e.preventDefault();
if (!runBtn.disabled) {
runCode();
}
}
// Tab key inserts spaces instead of changing focus
if (e.key === 'Tab') {
e.preventDefault();
var start = this.selectionStart;
var end = this.selectionEnd;
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 4;
}
});
// Stop all keyboard events from reaching SDL when REPL is focused
codeEditor.addEventListener('keyup', function(e) {
e.stopPropagation();
});
codeEditor.addEventListener('keypress', function(e) {
e.stopPropagation();
});
</script> </script>
{{{ SCRIPT }}} {{{ SCRIPT }}}
</body> </body>