1303 lines
47 KiB
HTML
1303 lines
47 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
<title>🐭 Mice! - Browser Edition</title>
|
|
<!-- Bootstrap CSS -->
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
padding: 10px;
|
|
font-family: Arial, sans-serif;
|
|
background-color: #1e1e1e;
|
|
color: #ffffff;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
h1 {
|
|
color: #ff9f43;
|
|
margin-bottom: 10px;
|
|
font-size: 1.8rem;
|
|
}
|
|
|
|
#status {
|
|
padding: 8px;
|
|
background-color: #2d2d2d;
|
|
border-radius: 5px;
|
|
font-family: monospace;
|
|
font-size: 0.85rem;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.canvas-container {
|
|
border: 2px solid #ff9f43;
|
|
border-radius: 5px;
|
|
background-color: #000;
|
|
width: 100%;
|
|
max-width: 640px;
|
|
aspect-ratio: 4/3;
|
|
position: relative;
|
|
}
|
|
|
|
#canvas {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
image-rendering: pixelated;
|
|
image-rendering: crisp-edges;
|
|
}
|
|
|
|
button, .btn {
|
|
background-color: #ff9f43 !important;
|
|
color: #1e1e1e !important;
|
|
border: none !important;
|
|
font-weight: bold;
|
|
}
|
|
|
|
button:hover, .btn:hover {
|
|
background-color: #ffb366 !important;
|
|
}
|
|
|
|
button:disabled, .btn:disabled {
|
|
background-color: #666 !important;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.info {
|
|
background-color: #2d2d2d;
|
|
padding: 15px;
|
|
border-radius: 5px;
|
|
margin: 15px 0;
|
|
}
|
|
|
|
.info h2 {
|
|
color: #ff9f43;
|
|
margin-top: 0;
|
|
font-size: 1.3rem;
|
|
}
|
|
|
|
code {
|
|
background-color: #1e1e1e;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.hud {
|
|
margin-top: 8px;
|
|
background: rgba(0,0,0,0.6);
|
|
padding: 6px 10px;
|
|
border-radius: 4px;
|
|
color: #dcdcdc;
|
|
font-family: monospace;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
input[type="range"] {
|
|
accent-color: #ff9f43;
|
|
}
|
|
|
|
progress {
|
|
accent-color: #ff9f43;
|
|
height: 8px;
|
|
}
|
|
|
|
select, .form-select {
|
|
background-color: #2d2d2d;
|
|
color: #fff;
|
|
border: 1px solid #555;
|
|
}
|
|
|
|
details summary {
|
|
cursor: pointer;
|
|
color: #ff9f43;
|
|
font-weight: bold;
|
|
}
|
|
|
|
details summary:hover {
|
|
color: #ffb366;
|
|
}
|
|
|
|
/* Mobile Touch Controls */
|
|
.touch-controls {
|
|
margin-top: 15px;
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
|
|
.dpad-container {
|
|
position: relative;
|
|
width: 160px;
|
|
height: 160px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.dpad-btn {
|
|
position: absolute;
|
|
width: 50px;
|
|
height: 50px;
|
|
background-color: #ff9f43;
|
|
border: 2px solid #1e1e1e;
|
|
border-radius: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1.5rem;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
touch-action: none;
|
|
transition: transform 0.1s ease, opacity 0.1s ease;
|
|
}
|
|
|
|
.dpad-btn:active {
|
|
background-color: #ffb366;
|
|
transform: scale(0.95);
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.dpad-up { top: 0; left: 55px; }
|
|
.dpad-down { bottom: 0; left: 55px; }
|
|
.dpad-left { top: 55px; left: 0; }
|
|
.dpad-right { top: 55px; right: 0; }
|
|
.dpad-center {
|
|
top: 55px;
|
|
left: 55px;
|
|
background-color: #2d2d2d;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.action-buttons {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 8px;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.action-btn {
|
|
height: 55px;
|
|
font-size: 0.95rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 5px;
|
|
touch-action: none;
|
|
transition: transform 0.1s ease, opacity 0.1s ease;
|
|
}
|
|
|
|
.action-btn:active {
|
|
transform: scale(0.95);
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.action-btn small {
|
|
font-size: 0.7rem;
|
|
font-weight: normal;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
/* Responsive adjustments */
|
|
@media (min-width: 768px) {
|
|
.touch-controls {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 767px) {
|
|
h1 { font-size: 1.5rem; }
|
|
.info h2 { font-size: 1.1rem; }
|
|
.info { padding: 10px; font-size: 0.9rem; }
|
|
.info ul { padding-left: 20px; }
|
|
|
|
/* Hide keyboard help on mobile */
|
|
.help { display: none; }
|
|
}
|
|
|
|
.form-label {
|
|
color: #dcdcdc;
|
|
}
|
|
|
|
.modal-content {
|
|
background-color: #2d2d2d;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.modal-header {
|
|
border-bottom: 1px solid #555;
|
|
}
|
|
|
|
.modal-footer {
|
|
border-top: 1px solid #555;
|
|
}
|
|
|
|
.form-control {
|
|
background-color: #1e1e1e;
|
|
color: #ffffff;
|
|
border: 1px solid #555;
|
|
}
|
|
|
|
.form-control:focus {
|
|
background-color: #1e1e1e;
|
|
color: #ffffff;
|
|
border-color: #ff9f43;
|
|
box-shadow: 0 0 0 0.25rem rgba(255, 159, 67, 0.25);
|
|
}
|
|
|
|
.form-check-input {
|
|
background-color: #1e1e1e;
|
|
border-color: #555;
|
|
}
|
|
|
|
.form-check-input:checked {
|
|
background-color: #ff9f43;
|
|
border-color: #ff9f43;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container-fluid">
|
|
<!-- Header -->
|
|
<div class="row">
|
|
<div class="col-12 text-center">
|
|
<h1>🐭 Mice! - The Game</h1>
|
|
<p class="mb-2"><strong>Eliminate the rats before they multiply!</strong></p>
|
|
<p class="small text-muted">Browser Edition powered by Python + Pygame via Pyodide</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status -->
|
|
<div class="row mb-2">
|
|
<div class="col-12">
|
|
<div id="status">Loading Pyodide...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Game Area -->
|
|
<div class="row">
|
|
<!-- Canvas Column -->
|
|
<div class="col-lg-8 col-12 mb-3">
|
|
<div class="canvas-container mx-auto">
|
|
<canvas id="canvas" width="640" height="480" aria-label="Mice game canvas"></canvas>
|
|
</div>
|
|
<div id="hud" class="hud text-center">Status: <span id="hud-status">Idle</span></div>
|
|
|
|
<!-- Mobile Touch Controls -->
|
|
<div class="touch-controls d-lg-none">
|
|
<div class="row">
|
|
<div class="col-6">
|
|
<h6 class="text-center text-muted mb-2">Movement</h6>
|
|
<div class="dpad-container">
|
|
<div class="dpad-btn dpad-up" data-key="ArrowUp">
|
|
<i class="bi bi-arrow-up"></i>
|
|
</div>
|
|
<div class="dpad-btn dpad-left" data-key="ArrowLeft">
|
|
<i class="bi bi-arrow-left"></i>
|
|
</div>
|
|
<div class="dpad-btn dpad-center"></div>
|
|
<div class="dpad-btn dpad-right" data-key="ArrowRight">
|
|
<i class="bi bi-arrow-right"></i>
|
|
</div>
|
|
<div class="dpad-btn dpad-down" data-key="ArrowDown">
|
|
<i class="bi bi-arrow-down"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6">
|
|
<h6 class="text-center text-muted mb-2">Actions</h6>
|
|
<div class="action-buttons">
|
|
<button class="btn action-btn" data-key="Enter">
|
|
▶️<br><small>Start</small>
|
|
</button>
|
|
<button class="btn action-btn" data-key=" ">
|
|
💣<br><small>Bomb</small>
|
|
</button>
|
|
<button class="btn action-btn" data-key="m">
|
|
⚠️<br><small>Mine</small>
|
|
</button>
|
|
<button class="btn action-btn" data-key="g">
|
|
☁️<br><small>Gas</small>
|
|
</button>
|
|
<button class="btn action-btn" data-key="n">
|
|
☢️<br><small>Nuclear</small>
|
|
</button>
|
|
<button class="btn action-btn" data-key="p">
|
|
⏸️<br><small>Pause</small>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Controls Sidebar -->
|
|
<div class="col-lg-4 col-12">
|
|
<!-- Game Controls -->
|
|
<div class="mb-3">
|
|
<div class="d-flex flex-wrap gap-2">
|
|
<button id="startBtn" class="btn flex-fill" disabled>Start Game</button>
|
|
<button id="stopBtn" class="btn flex-fill" disabled>Stop Game</button>
|
|
<button id="fullscreenBtn" class="btn flex-fill">Fullscreen</button>
|
|
<button id="muteBtn" class="btn flex-fill">Mute</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Volume Control -->
|
|
<div class="mb-3">
|
|
<label for="volumeRange" class="form-label">Volume</label>
|
|
<input id="volumeRange" type="range" class="form-range" min="0" max="100" value="100">
|
|
</div>
|
|
|
|
<!-- Profile Selection -->
|
|
<div class="mb-3">
|
|
<label for="profileSelect" class="form-label">Profile</label>
|
|
<div class="d-flex gap-2">
|
|
<select id="profileSelect" class="form-select flex-fill">
|
|
<option value="">(None)</option>
|
|
</select>
|
|
<button id="loadProfileBtn" class="btn">Load</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<button id="newProfileBtn" class="btn w-100">+ New Profile</button>
|
|
</div>
|
|
|
|
<!-- Progress -->
|
|
<div class="mb-3">
|
|
<label class="form-label">Asset Load Progress</label>
|
|
<div class="progress" style="height: 8px;">
|
|
<div id="assetProgress" class="progress-bar" role="progressbar" style="width: 0%; background-color: #ff9f43;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
|
</div>
|
|
<small id="assetProgressText" class="text-muted">0 / 0</small>
|
|
</div>
|
|
|
|
<!-- Advanced Options -->
|
|
<details class="mb-3">
|
|
<summary>Advanced</summary>
|
|
<div class="mt-2">
|
|
<button id="runDemoBtn" class="btn w-100">Run Pygame Demo</button>
|
|
<small class="d-block mt-2 text-muted">Useful for testing the canvas rendering when debugging.</small>
|
|
</div>
|
|
</details>
|
|
|
|
<!-- Keyboard Controls Help (Desktop Only) -->
|
|
<div class="help d-none d-lg-block">
|
|
<h5>Keyboard Controls</h5>
|
|
<ul class="small">
|
|
<li>Arrow keys / WASD — move</li>
|
|
<li>Space — bomb</li>
|
|
<li>M — mine</li>
|
|
<li>G — gas</li>
|
|
<li>N — nuclear</li>
|
|
<li>P — pause</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info Section -->
|
|
<div class="row mt-3">
|
|
<div class="col-12">
|
|
<div class="info">
|
|
<h2>🎮 How to Play</h2>
|
|
<p>Move your cursor around the maze and use your arsenal to eliminate rats before they overrun the level:</p>
|
|
<ul>
|
|
<li><strong>Bombs (Space)</strong>: Primary weapon. Timer detonates in 4 directions. Chain reactions kill multiple rats.</li>
|
|
<li><strong>Mines (M)</strong>: Place traps in corridors. Triggers when rats step on them, releasing poison gas.</li>
|
|
<li><strong>Gas (G)</strong>: Poison clouds linger and accumulate damage. Great for rat-dense areas.</li>
|
|
<li><strong>Nuclear Bomb (N)</strong>: One-time use. Destroys all rats on screen instantly.</li>
|
|
<li><strong>Pause (P)</strong>: Stop and plan your strategy.</li>
|
|
</ul>
|
|
<h3 style="color:#ff9f43;margin-top:15px;">💡 Tips & Strategy</h3>
|
|
<ul style="font-size:0.95em;">
|
|
<li>Early game: Target adult rats to prevent reproduction (200+ ticks).</li>
|
|
<li>Use walls to direct bomb explosions efficiently.</li>
|
|
<li>Rat population limit: 200. Exceeding this = Game Over.</li>
|
|
<li>Collect bonus points by killing multiple rats with a single bomb.</li>
|
|
<li>Save the nuclear bomb for when rats get out of control.</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Profile Creation Modal (Bootstrap Modal) -->
|
|
<div class="modal fade" id="profileModal" tabindex="-1" aria-labelledby="profileModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="profileModalLabel">Create New Profile</h5>
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="profileForm">
|
|
<div class="mb-3">
|
|
<label for="profileName" class="form-label">Player Name:</label>
|
|
<input type="text" id="profileName" name="profileName" class="form-control" placeholder="Enter your name" required>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="difficulty" class="form-label">Difficulty:</label>
|
|
<select id="difficulty" name="difficulty" class="form-select">
|
|
<option value="normal">Normal</option>
|
|
<option value="hard">Hard</option>
|
|
<option value="expert">Expert</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<div class="form-check">
|
|
<input type="checkbox" id="soundEnabled" name="soundEnabled" class="form-check-input" checked>
|
|
<label class="form-check-label" for="soundEnabled">Enable Sound</label>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn" id="saveProfileBtn">Create Profile</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bootstrap JS Bundle -->
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
|
|
<script src="https://ryanking13.github.io/pyodide-pygame-demo/dist/pyodide.js"></script>
|
|
<script type="text/javascript">
|
|
// Using the original pyodide.js from the pyodide-pygame-demo distribution
|
|
let pyodide = null;
|
|
let gameTask = null;
|
|
let animationHandle = null;
|
|
let animationRunning = false;
|
|
|
|
const statusDiv = document.getElementById('status');
|
|
const startBtn = document.getElementById('startBtn');
|
|
const stopBtn = document.getElementById('stopBtn');
|
|
const canvas = document.getElementById('canvas');
|
|
|
|
// Touch Controls - Simulate keyboard events
|
|
function simulateKeyPress(key, type = 'keydown') {
|
|
// Map keys to proper keyCodes
|
|
const keyCodeMap = {
|
|
'ArrowUp': 38,
|
|
'ArrowDown': 40,
|
|
'ArrowLeft': 37,
|
|
'ArrowRight': 39,
|
|
' ': 32, // Space
|
|
'Enter': 13,
|
|
'm': 77,
|
|
'M': 77,
|
|
'g': 71,
|
|
'G': 71,
|
|
'n': 78,
|
|
'N': 78,
|
|
'p': 80,
|
|
'P': 80
|
|
};
|
|
|
|
const keyCode = keyCodeMap[key] || key.toUpperCase().charCodeAt(0);
|
|
|
|
// Determine the correct code property
|
|
let code;
|
|
if (key.startsWith('Arrow')) {
|
|
code = key; // ArrowUp, ArrowDown, etc.
|
|
} else if (key === ' ') {
|
|
code = 'Space';
|
|
} else if (key === 'Enter') {
|
|
code = 'Enter';
|
|
} else {
|
|
code = `Key${key.toUpperCase()}`;
|
|
}
|
|
|
|
const event = new KeyboardEvent(type, {
|
|
key: key,
|
|
code: code,
|
|
keyCode: keyCode,
|
|
which: keyCode,
|
|
bubbles: true,
|
|
cancelable: true
|
|
});
|
|
|
|
console.log(`[Touch] Simulating ${type} for key="${key}", code="${code}", keyCode=${keyCode}`);
|
|
|
|
// Dispatch to both document and canvas
|
|
document.dispatchEvent(event);
|
|
canvas.dispatchEvent(event);
|
|
|
|
// Also try dispatching to window for better compatibility
|
|
window.dispatchEvent(event);
|
|
}
|
|
|
|
// Setup touch controls
|
|
function setupTouchControls() {
|
|
// D-Pad and Action buttons
|
|
document.querySelectorAll('.dpad-btn, .action-btn').forEach(btn => {
|
|
const key = btn.dataset.key;
|
|
if (!key) return;
|
|
|
|
let isPressed = false;
|
|
|
|
// Touch start - simulate key down
|
|
btn.addEventListener('touchstart', (e) => {
|
|
e.preventDefault();
|
|
if (isPressed) return;
|
|
isPressed = true;
|
|
btn.style.transform = 'scale(0.95)';
|
|
btn.style.opacity = '0.7';
|
|
simulateKeyPress(key, 'keydown');
|
|
}, { passive: false });
|
|
|
|
// Touch end - simulate key up
|
|
btn.addEventListener('touchend', (e) => {
|
|
e.preventDefault();
|
|
if (!isPressed) return;
|
|
isPressed = false;
|
|
btn.style.transform = 'scale(1)';
|
|
btn.style.opacity = '1';
|
|
simulateKeyPress(key, 'keyup');
|
|
}, { passive: false });
|
|
|
|
// Touch cancel (when finger moves away)
|
|
btn.addEventListener('touchcancel', (e) => {
|
|
e.preventDefault();
|
|
if (!isPressed) return;
|
|
isPressed = false;
|
|
btn.style.transform = 'scale(1)';
|
|
btn.style.opacity = '1';
|
|
simulateKeyPress(key, 'keyup');
|
|
}, { passive: false });
|
|
|
|
// Mouse support for testing on desktop
|
|
btn.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
if (isPressed) return;
|
|
isPressed = true;
|
|
btn.style.transform = 'scale(0.95)';
|
|
btn.style.opacity = '0.7';
|
|
simulateKeyPress(key, 'keydown');
|
|
});
|
|
|
|
btn.addEventListener('mouseup', (e) => {
|
|
e.preventDefault();
|
|
if (!isPressed) return;
|
|
isPressed = false;
|
|
btn.style.transform = 'scale(1)';
|
|
btn.style.opacity = '1';
|
|
simulateKeyPress(key, 'keyup');
|
|
});
|
|
|
|
btn.addEventListener('mouseleave', (e) => {
|
|
if (!isPressed) return;
|
|
isPressed = false;
|
|
btn.style.transform = 'scale(1)';
|
|
btn.style.opacity = '1';
|
|
simulateKeyPress(key, 'keyup');
|
|
});
|
|
|
|
// Prevent context menu on long press
|
|
btn.addEventListener('contextmenu', (e) => {
|
|
e.preventDefault();
|
|
});
|
|
});
|
|
}
|
|
|
|
function updateStatus(message) {
|
|
statusDiv.textContent = message;
|
|
console.log(message);
|
|
}
|
|
|
|
async function fetchAndWrite(path) {
|
|
const res = await fetch(path);
|
|
if (!res.ok) throw new Error(`Failed to fetch ${path}: ${res.status}`);
|
|
const text = await res.text();
|
|
// Write file into pyodide FS later via pyodide.FS
|
|
return { path, text };
|
|
}
|
|
|
|
async function loadProjectFiles() {
|
|
// Minimal set of files/folders required to run rats.py in the browser.
|
|
// We'll fetch top-level modules and small resources. Large binary assets (audio) are skipped.
|
|
const toFetch = [
|
|
'rats.py',
|
|
'maze.json',
|
|
'key.py',
|
|
];
|
|
|
|
// fetch engine/ and units/ Python files
|
|
const engineFiles = [
|
|
'engine/controls.py','engine/graphics.py','engine/maze.py','engine/pygame_layer.py',
|
|
'engine/score_api_client.py','engine/scoring.py','engine/unit_manager.py','engine/user_profile_integration.py'
|
|
];
|
|
const unitFiles = [
|
|
'units/__init__.py','units/bomb.py','units/gas.py','units/mine.py','units/points.py','units/rat.py','units/unit.py'
|
|
];
|
|
|
|
const confFiles = [
|
|
'conf/keybindings.yaml', 'conf/keybindings_r36s.yaml', 'conf/keybindings_rg40xx.yaml', 'conf/keybindings.json'
|
|
];
|
|
|
|
const all = toFetch.concat(engineFiles, unitFiles, confFiles);
|
|
|
|
// Asset manifest (try to fetch a manifest file listing asset paths)
|
|
let assetFiles = null;
|
|
let soundFiles = null;
|
|
try {
|
|
const mres = await fetch('assets/asset-manifest.json');
|
|
if (mres.ok) {
|
|
assetFiles = await mres.json();
|
|
}
|
|
} catch (e) {
|
|
console.warn('No asset-manifest.json found, will use built-in asset list');
|
|
}
|
|
|
|
// Sound manifest: include all sounds under sound/ into FS
|
|
try {
|
|
const sres = await fetch('assets/sound-manifest.json');
|
|
if (sres.ok) {
|
|
soundFiles = await sres.json();
|
|
}
|
|
} catch (e) {
|
|
console.warn('No sound-manifest.json found');
|
|
}
|
|
|
|
if (!assetFiles) {
|
|
assetFiles = [
|
|
'assets/Rat/BMP_TUNNEL.png',
|
|
'assets/Rat/BMP_WEWIN.png',
|
|
'assets/Rat/BMP_BOMB0.png',
|
|
'assets/Rat/BMP_POISON.png',
|
|
'assets/Rat/BMP_GAS.png',
|
|
'assets/Rat/BMP_EXPLOSION.png',
|
|
'assets/Rat/BMP_MALE_DOWN.png',
|
|
'assets/Rat/BMP_FEMALE_DOWN.png',
|
|
'assets/Rat/BMP_BONUS_10.png',
|
|
'assets/Rat/BMP_START_1.png',
|
|
'assets/Rat/BMP_TITLE.png',
|
|
'assets/decterm.ttf',
|
|
'assets/AmaticSC-Regular.ttf',
|
|
'assets/terminal.ttf',
|
|
'user_profile.json'
|
|
];
|
|
}
|
|
|
|
if (soundFiles && soundFiles.length) {
|
|
for (const sf of soundFiles) {
|
|
// Sound files live under 'sound/' at project root; use path as-is
|
|
const path = sf;
|
|
if (!assetFiles.includes(path)) assetFiles.push(path);
|
|
}
|
|
}
|
|
|
|
updateStatus('Fetching project files...');
|
|
// Fetch text files; if optional files are missing, skip them instead of throwing.
|
|
const textPromises = all.map(async (p) => {
|
|
try {
|
|
return await fetchAndWrite(p);
|
|
} catch (e) {
|
|
console.warn('Skipping missing text file:', p, e);
|
|
return null;
|
|
}
|
|
});
|
|
|
|
// For binary assets use arrayBuffer and write as binary. Skip missing binaries.
|
|
const binPromises = assetFiles.map(async (path) => {
|
|
try {
|
|
const res = await fetch(path);
|
|
if (!res.ok) {
|
|
console.warn(`Skipping missing binary asset (status ${res.status}): ${path}`);
|
|
return null;
|
|
}
|
|
const buf = await res.arrayBuffer();
|
|
return { path, buffer: buf };
|
|
} catch (e) {
|
|
console.warn('Skipping binary asset fetch error:', path, e);
|
|
return null;
|
|
}
|
|
});
|
|
|
|
const texts = (await Promise.all(textPromises)).filter(x => x);
|
|
const bins = (await Promise.all(binPromises)).filter(x => x);
|
|
return { texts, bins, totalCount: texts.length + bins.length };
|
|
}
|
|
|
|
async function loadPyodideAndSetup() {
|
|
updateStatus('Loading Pyodide...');
|
|
// The included pyodide.js exposes a global loadPyodide().
|
|
// Use the same base URL as the bundled pyodide.js so versions match.
|
|
pyodide = await loadPyodide({ indexURL: 'https://ryanking13.github.io/pyodide-pygame-demo/dist/' });
|
|
|
|
updateStatus('Configuring canvas for SDL2/pygame...');
|
|
try {
|
|
// Canvas helpers provided by pygame-ce's pyodide integration
|
|
pyodide.canvas.setCanvas2D(canvas);
|
|
} catch (e) {
|
|
console.warn('Could not set canvas via pyodide.canvas:', e);
|
|
}
|
|
|
|
updateStatus('Installing required Python packages (pyyaml, requests, pygame-ce)...');
|
|
// Install runtime deps: prefer pyodide built-in packages, fall back to micropip.
|
|
try {
|
|
|
|
// Ensure requests is available
|
|
try {
|
|
await pyodide.loadPackage(['pygame-ce']);
|
|
updateStatus('Loaded built-in requests');
|
|
} catch (e) {
|
|
console.warn('Failed to load built-in requests:', e);
|
|
}
|
|
|
|
} catch (e) {
|
|
console.warn('Package installation issue:', e);
|
|
}
|
|
|
|
// Quick runtime check for yaml import
|
|
try {
|
|
await pyodide.runPythonAsync(`
|
|
try:
|
|
import yaml
|
|
print('PY: yaml OK')
|
|
except Exception as e:
|
|
print('PY: yaml ERROR', e)
|
|
`);
|
|
updateStatus('Package import check complete (see console for details)');
|
|
} catch (e) {
|
|
console.warn('Import check failed:', e);
|
|
updateStatus('Package import check failed: ' + e.message);
|
|
}
|
|
|
|
updateStatus('Loading project files into filesystem...');
|
|
const { texts, bins } = await loadProjectFiles();
|
|
|
|
// Setup asset progress UI
|
|
const assetProgress = document.getElementById('assetProgress');
|
|
const assetProgressText = document.getElementById('assetProgressText');
|
|
let writtenCount = 0;
|
|
const totalCount = texts.length + bins.length;
|
|
|
|
function incrementProgress() {
|
|
writtenCount += 1;
|
|
const percentage = Math.round((writtenCount / totalCount) * 100);
|
|
assetProgress.style.width = percentage + '%';
|
|
assetProgress.setAttribute('aria-valuenow', percentage);
|
|
assetProgressText.textContent = `${writtenCount} / ${totalCount}`;
|
|
document.getElementById('hud-status').textContent = `Loading assets ${writtenCount}/${totalCount}`;
|
|
}
|
|
|
|
// Write text files into pyodide filesystem
|
|
for (const f of texts) {
|
|
const dir = f.path.substring(0, f.path.lastIndexOf('/'));
|
|
if (dir) {
|
|
try { pyodide.FS.mkdirTree(dir); } catch (e) { /* ignore */ }
|
|
}
|
|
|
|
// If this is a YAML config file, also produce a JSON counterpart so Python can read JSON without PyYAML.
|
|
if (f.path.endsWith('.yaml') || f.path.endsWith('.yml')) {
|
|
try {
|
|
const obj = jsyaml.load(f.text);
|
|
const jsonPath = f.path.replace(/\.ya?ml$/i, '.json');
|
|
pyodide.FS.writeFile(jsonPath, JSON.stringify(obj, null, 2));
|
|
console.log('Wrote JSON config:', jsonPath);
|
|
} catch (e) {
|
|
console.warn('Failed to parse YAML in browser for', f.path, e);
|
|
}
|
|
}
|
|
|
|
pyodide.FS.writeFile(f.path, f.text);
|
|
incrementProgress();
|
|
}
|
|
|
|
// Write binary assets
|
|
for (const b of bins) {
|
|
const dir = b.path.substring(0, b.path.lastIndexOf('/'));
|
|
if (dir) {
|
|
try { pyodide.FS.mkdirTree(dir); } catch (e) { /* ignore */ }
|
|
}
|
|
const arr = new Uint8Array(b.buffer);
|
|
pyodide.FS.writeFile(b.path, arr);
|
|
console.log('Wrote binary asset:', b.path);
|
|
incrementProgress();
|
|
}
|
|
|
|
updateStatus('Ready. You can Start the game.');
|
|
document.getElementById('hud-status').textContent = 'Ready';
|
|
startBtn.disabled = false;
|
|
stopBtn.disabled = true;
|
|
|
|
// Initialize touch controls
|
|
setupTouchControls();
|
|
|
|
// Populate profile dropdown from project file if available
|
|
try {
|
|
const pres = await fetch('user_profiles.json');
|
|
if (pres.ok) {
|
|
const pjson = await pres.json();
|
|
populateProfiles(pjson);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not fetch profiles for dropdown', e);
|
|
}
|
|
|
|
// Load current profile from localStorage into Pyodide FS
|
|
console.log('[DEBUG] About to call loadProfileToFS()');
|
|
loadProfileToFS();
|
|
console.log('[DEBUG] loadProfileToFS() completed');
|
|
|
|
// Verify file was written
|
|
try {
|
|
if (pyodide && pyodide.FS) {
|
|
const content = pyodide.FS.readFile('user_profiles.json', { encoding: 'utf8' });
|
|
console.log('[DEBUG] Verification: user_profiles.json exists in Pyodide FS, content:', content);
|
|
}
|
|
} catch (e) {
|
|
console.warn('[DEBUG] Verification failed: Could not read user_profiles.json from Pyodide FS', e);
|
|
}
|
|
}
|
|
|
|
async function startGame() {
|
|
if (!pyodide) return;
|
|
startBtn.disabled = true;
|
|
stopBtn.disabled = false;
|
|
updateStatus('Starting game (this may take a few seconds)...');
|
|
|
|
try {
|
|
// Initialize the Python solver object but do NOT run its blocking mainloop.
|
|
await pyodide.runPythonAsync(`
|
|
import sys
|
|
sys.argv = ['rats.py']
|
|
from importlib import reload
|
|
import rats
|
|
reload(rats)
|
|
solver = rats.MiceMaze('maze.json')
|
|
`);
|
|
updateStatus('Game initialized. Running...');
|
|
|
|
// Start a JS-driven animation loop that calls solver.tick() each frame.
|
|
animationRunning = true;
|
|
const frame = async () => {
|
|
if (!animationRunning) return;
|
|
try {
|
|
await pyodide.runPythonAsync(`solver.tick()`);
|
|
} catch (e) {
|
|
console.error('Error during solver.tick():', e);
|
|
// Stop on errors
|
|
animationRunning = false;
|
|
return;
|
|
}
|
|
animationHandle = requestAnimationFrame(frame);
|
|
};
|
|
animationHandle = requestAnimationFrame(frame);
|
|
updateStatus('Game started. Use Stop to terminate.');
|
|
|
|
// Start periodic syncing of profile updates from Pyodide FS
|
|
startProfileSync();
|
|
} catch (e) {
|
|
console.error('Error starting game:', e);
|
|
updateStatus('Error: ' + e.message);
|
|
startBtn.disabled = false;
|
|
stopBtn.disabled = true;
|
|
}
|
|
}
|
|
|
|
async function stopGame() {
|
|
if (!pyodide) return;
|
|
updateStatus('Stopping game...');
|
|
animationRunning = false;
|
|
if (animationHandle) {
|
|
cancelAnimationFrame(animationHandle);
|
|
animationHandle = null;
|
|
}
|
|
// Attempt to request a clean stop in Python
|
|
try {
|
|
await pyodide.runPythonAsync(`
|
|
try:
|
|
if 'solver' in globals():
|
|
try:
|
|
solver.render_engine.running = False
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
print('stop error', e)
|
|
`);
|
|
} catch (e) {
|
|
console.warn('Stop call failed:', e);
|
|
}
|
|
startBtn.disabled = false;
|
|
stopBtn.disabled = true;
|
|
updateStatus('Stopped. You can Start again.');
|
|
|
|
// Stop profile syncing and do final sync
|
|
stopProfileSync();
|
|
}
|
|
|
|
startBtn.addEventListener('click', startGame);
|
|
stopBtn.addEventListener('click', stopGame);
|
|
document.getElementById('runDemoBtn').addEventListener('click', async () => {
|
|
if (!pyodide) return;
|
|
updateStatus('Writing demo script to FS and starting demo...');
|
|
const demoCode = `"""
|
|
Pygame WebAssembly Demo
|
|
"""
|
|
|
|
import asyncio, random, math, pygame
|
|
|
|
WINDOW_WIDTH = 640
|
|
WINDOW_HEIGHT = 480
|
|
|
|
class Ball:
|
|
def __init__(self):
|
|
self.x = random.uniform(20, WINDOW_WIDTH-20)
|
|
self.y = random.uniform(20, WINDOW_HEIGHT-20)
|
|
self.vx = random.uniform(-3, 3)
|
|
self.vy = random.uniform(-3, 3)
|
|
self.radius = random.randint(8, 20)
|
|
self.color = (random.randint(50,255), random.randint(50,255), random.randint(50,255))
|
|
|
|
def update(self):
|
|
self.x += self.vx
|
|
self.y += self.vy
|
|
if self.x < self.radius or self.x > WINDOW_WIDTH - self.radius:
|
|
self.vx = -self.vx
|
|
if self.y < self.radius or self.y > WINDOW_HEIGHT - self.radius:
|
|
self.vy = -self.vy
|
|
|
|
def draw(self, surf):
|
|
pygame.draw.circle(surf, self.color, (int(self.x), int(self.y)), self.radius)
|
|
|
|
balls = [Ball() for _ in range(8)]
|
|
|
|
def init():
|
|
pygame.init()
|
|
pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
|
|
pygame.display.set_caption('Demo')
|
|
|
|
async def main():
|
|
init()
|
|
clock = pygame.time.Clock()
|
|
frame = 0
|
|
try:
|
|
while True:
|
|
for event in pygame.event.get():
|
|
if event.type == pygame.QUIT:
|
|
return
|
|
surf = pygame.display.get_surface()
|
|
surf.fill((10,10,30))
|
|
for b in balls:
|
|
b.update()
|
|
b.draw(surf)
|
|
pygame.display.flip()
|
|
frame += 1
|
|
await asyncio.sleep(1/60)
|
|
finally:
|
|
pygame.quit()
|
|
|
|
asyncio.create_task(main())
|
|
`;
|
|
|
|
try {
|
|
pyodide.FS.writeFile('/demo_pygame.py', demoCode);
|
|
await pyodide.runPythonAsync(`
|
|
import importlib
|
|
import demo_pygame
|
|
`);
|
|
updateStatus('Demo started (check canvas).');
|
|
} catch (e) {
|
|
console.error('Demo failed:', e);
|
|
updateStatus('Demo failed: ' + e.message);
|
|
}
|
|
});
|
|
|
|
// UI helpers: profiles, fullscreen, mute/volume
|
|
function populateProfiles(profiles) {
|
|
const sel = document.getElementById('profileSelect');
|
|
// profiles expected as {profiles: [{id,name,...}, ...]} or an array
|
|
let list = profiles;
|
|
if (profiles && profiles.profiles) list = profiles.profiles;
|
|
if (!Array.isArray(list)) return;
|
|
for (const p of list) {
|
|
const opt = document.createElement('option');
|
|
opt.value = p.id || p.name || JSON.stringify(p);
|
|
opt.textContent = p.name || p.id || opt.value;
|
|
sel.appendChild(opt);
|
|
}
|
|
}
|
|
|
|
document.getElementById('fullscreenBtn').addEventListener('click', () => {
|
|
if (!document.fullscreenElement) {
|
|
document.querySelector('.canvas-container').requestFullscreen().catch(err => console.warn('FS error', err));
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
});
|
|
|
|
let muted = false;
|
|
const muteBtn = document.getElementById('muteBtn');
|
|
muteBtn.addEventListener('click', () => {
|
|
muted = !muted;
|
|
muteBtn.textContent = muted ? 'Unmute' : 'Mute';
|
|
// attempt to mute audio via Pyodide FS or global audio objects later; for now just set flag
|
|
console.log('Mute toggled:', muted);
|
|
});
|
|
|
|
document.getElementById('volumeRange').addEventListener('input', (e) => {
|
|
const v = Number(e.target.value) / 100;
|
|
console.log('Volume set to', v);
|
|
// If Python exposes an audio mixer, we could call it via pyodide.runPythonAsync
|
|
});
|
|
|
|
document.getElementById('loadProfileBtn').addEventListener('click', () => {
|
|
const sel = document.getElementById('profileSelect');
|
|
const profileName = sel.value;
|
|
if (!profileName) return alert('Select a profile first');
|
|
|
|
// Store as current profile
|
|
localStorage.setItem('currentProfile', profileName);
|
|
|
|
updateStatus('Profile selected: ' + profileName);
|
|
|
|
// Write to Pyodide FS immediately if available
|
|
if (pyodide) {
|
|
loadProfileToFS();
|
|
}
|
|
|
|
// Refresh dropdown to show (current) marker
|
|
populateProfileDropdown();
|
|
});
|
|
|
|
// Profile management: localStorage + Bootstrap modal
|
|
let profileModal = null;
|
|
|
|
function initProfileModal() {
|
|
// Initialize Bootstrap modal
|
|
const modalElement = document.getElementById('profileModal');
|
|
profileModal = new bootstrap.Modal(modalElement);
|
|
|
|
// Handle save button click
|
|
document.getElementById('saveProfileBtn').addEventListener('click', () => {
|
|
saveProfile();
|
|
profileModal.hide();
|
|
});
|
|
}
|
|
|
|
function saveProfile() {
|
|
const name = document.getElementById('profileName').value;
|
|
const difficulty = document.getElementById('difficulty').value;
|
|
const soundEnabled = document.getElementById('soundEnabled').checked;
|
|
|
|
if (!name || name.trim() === '') {
|
|
alert('Please enter a player name');
|
|
return;
|
|
}
|
|
|
|
// Create profile object matching UserProfileIntegration format
|
|
const profile = {
|
|
name: name,
|
|
difficulty: difficulty,
|
|
settings: {
|
|
difficulty: difficulty,
|
|
sound_volume: 100,
|
|
sound_enabled: soundEnabled
|
|
},
|
|
games_played: 0,
|
|
best_score: 0,
|
|
total_score: 0,
|
|
achievements: [],
|
|
last_played: null
|
|
};
|
|
|
|
// Get existing profiles from localStorage (as array)
|
|
let profilesArray = [];
|
|
try {
|
|
const stored = localStorage.getItem('miceProfiles');
|
|
if (stored) profilesArray = JSON.parse(stored);
|
|
} catch (e) {
|
|
console.warn('Could not read stored profiles', e);
|
|
}
|
|
|
|
// Add new profile
|
|
profilesArray.push(profile);
|
|
localStorage.setItem('miceProfiles', JSON.stringify(profilesArray));
|
|
localStorage.setItem('currentProfile', name);
|
|
|
|
// Reset form
|
|
document.getElementById('profileForm').reset();
|
|
updateStatus(`Profile "${name}" created!`);
|
|
console.log('Profile saved:', profile);
|
|
|
|
// Refresh profile dropdown
|
|
populateProfileDropdown();
|
|
}
|
|
|
|
function populateProfileDropdown() {
|
|
const sel = document.getElementById('profileSelect');
|
|
sel.innerHTML = '<option value="">(None)</option>';
|
|
|
|
let profilesArray = [];
|
|
try {
|
|
const stored = localStorage.getItem('miceProfiles');
|
|
if (stored) profilesArray = JSON.parse(stored);
|
|
} catch (e) {
|
|
console.warn('Could not read stored profiles', e);
|
|
}
|
|
|
|
const currentProfileName = localStorage.getItem('currentProfile');
|
|
profilesArray.forEach(p => {
|
|
const opt = document.createElement('option');
|
|
opt.value = p.name; // Use name as the value
|
|
opt.textContent = p.name + (p.name === currentProfileName ? ' (current)' : '');
|
|
sel.appendChild(opt);
|
|
});
|
|
|
|
// Auto-select current profile
|
|
if (currentProfileName) sel.value = currentProfileName;
|
|
}
|
|
|
|
document.getElementById('newProfileBtn').addEventListener('click', () => {
|
|
profileModal.show();
|
|
});
|
|
|
|
// Expose a function that Python can call to sync profile updates back to localStorage
|
|
window.syncProfileUpdateToLocalStorage = function(profileName, bestScore, gamesPlayed, totalScore) {
|
|
console.log(`[DEBUG SYNC] Python calling sync: profile=${profileName}, best=${bestScore}, games=${gamesPlayed}, total=${totalScore}`);
|
|
try {
|
|
// Get existing profiles from localStorage
|
|
let profilesArray = [];
|
|
const stored = localStorage.getItem('miceProfiles');
|
|
if (stored) {
|
|
profilesArray = JSON.parse(stored);
|
|
}
|
|
|
|
// Find and update the profile
|
|
const profileIndex = profilesArray.findIndex(p => p.name === profileName);
|
|
if (profileIndex >= 0) {
|
|
profilesArray[profileIndex].best_score = bestScore;
|
|
profilesArray[profileIndex].games_played = gamesPlayed;
|
|
profilesArray[profileIndex].total_score = totalScore;
|
|
console.log(`[DEBUG SYNC] Updated profile in array at index ${profileIndex}`);
|
|
}
|
|
|
|
// Save back to localStorage
|
|
localStorage.setItem('miceProfiles', JSON.stringify(profilesArray));
|
|
console.log('[DEBUG SYNC] Successfully saved profile updates to localStorage');
|
|
return true;
|
|
} catch (e) {
|
|
console.error('[DEBUG SYNC] Failed to sync profile update:', e);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Sync profile changes from Pyodide FS back to localStorage (fallback method)
|
|
function syncProfileFromPyodideFS() {
|
|
if (!pyodide) return false;
|
|
|
|
try {
|
|
console.log('[DEBUG SYNC] Attempting to sync profile from Pyodide FS back to localStorage');
|
|
|
|
// Check if file exists first
|
|
try {
|
|
pyodide.FS.stat('user_profiles.json');
|
|
} catch (e) {
|
|
console.log('[DEBUG SYNC] user_profiles.json not found in Pyodide FS (this is normal during game), skipping read');
|
|
return false;
|
|
}
|
|
|
|
const content = pyodide.FS.readFile('user_profiles.json', { encoding: 'utf8' });
|
|
const profileData = JSON.parse(content);
|
|
|
|
console.log('[DEBUG SYNC] Read profile data from Pyodide FS:', profileData);
|
|
|
|
// Convert from dict format back to array format for localStorage
|
|
const profilesArray = Object.values(profileData.profiles || {});
|
|
console.log('[DEBUG SYNC] Converted to array, count:', profilesArray.length);
|
|
|
|
// Save back to localStorage
|
|
localStorage.setItem('miceProfiles', JSON.stringify(profilesArray));
|
|
if (profileData.active_profile) {
|
|
localStorage.setItem('currentProfile', profileData.active_profile);
|
|
}
|
|
|
|
console.log('[DEBUG SYNC] Successfully synced profiles back to localStorage');
|
|
return true;
|
|
} catch (e) {
|
|
console.warn('[DEBUG SYNC] Failed to sync profiles from Pyodide FS:', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Periodically try to sync profiles back from Pyodide FS (every 5 seconds during game)
|
|
let syncInterval = null;
|
|
function startProfileSync() {
|
|
console.log('[DEBUG] Starting periodic profile sync');
|
|
if (syncInterval) clearInterval(syncInterval);
|
|
syncInterval = setInterval(() => {
|
|
syncProfileFromPyodideFS();
|
|
}, 5000);
|
|
}
|
|
|
|
function stopProfileSync() {
|
|
console.log('[DEBUG] Stopping periodic profile sync');
|
|
if (syncInterval) {
|
|
clearInterval(syncInterval);
|
|
syncInterval = null;
|
|
}
|
|
// Do a final sync attempt on stop
|
|
syncProfileFromPyodideFS();
|
|
}
|
|
function loadProfileToFS() {
|
|
if (!pyodide) return;
|
|
|
|
console.log('[DEBUG] loadProfileToFS called');
|
|
|
|
let profilesArray = [];
|
|
try {
|
|
const stored = localStorage.getItem('miceProfiles');
|
|
console.log('[DEBUG] Stored profiles from localStorage:', stored);
|
|
if (stored) profilesArray = JSON.parse(stored);
|
|
} catch (e) {
|
|
console.warn('Could not read stored profiles', e);
|
|
}
|
|
|
|
// Get current profile name
|
|
const currentProfileName = localStorage.getItem('currentProfile');
|
|
console.log('[DEBUG] Current profile name from localStorage:', currentProfileName);
|
|
console.log('[DEBUG] Total profiles in array:', profilesArray.length);
|
|
|
|
// Convert array of profiles to dict format expected by UserProfileIntegration
|
|
// Format: { profiles: { "name": {...}, ... }, active_profile: "name" }
|
|
const profilesDict = {};
|
|
profilesArray.forEach(p => {
|
|
profilesDict[p.name] = p;
|
|
console.log('[DEBUG] Added profile to dict:', p.name);
|
|
});
|
|
|
|
const profileData = {
|
|
profiles: profilesDict,
|
|
active_profile: currentProfileName || null
|
|
};
|
|
|
|
console.log('[DEBUG] Final profile data to write to FS:', JSON.stringify(profileData, null, 2));
|
|
|
|
try {
|
|
pyodide.FS.writeFile('user_profiles.json', JSON.stringify(profileData, null, 2));
|
|
console.log('[DEBUG] Successfully wrote user_profiles.json to Pyodide FS');
|
|
console.log('[DEBUG] Wrote profiles to Pyodide FS:', profileData);
|
|
updateStatus('Profile loaded: ' + (currentProfileName ? currentProfileName : 'None'));
|
|
} catch (e) {
|
|
console.warn('[DEBUG] Failed to write profile to FS', e);
|
|
}
|
|
}
|
|
|
|
// Initialize on page load
|
|
window.addEventListener('load', () => {
|
|
initProfileModal();
|
|
populateProfileDropdown();
|
|
loadPyodideAndSetup().catch(err => {
|
|
console.error('Failed to initialize Pyodide:', err);
|
|
updateStatus('Failed to load Pyodide: ' + err.message);
|
|
});
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|