You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1302 lines
47 KiB

<!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'
];
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>