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.
966 lines
36 KiB
966 lines
36 KiB
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>🐭 Mice! - Browser Edition</title> |
|
<style> |
|
body { |
|
margin: 0; |
|
padding: 20px; |
|
font-family: Arial, sans-serif; |
|
background-color: #1e1e1e; |
|
color: #ffffff; |
|
display: flex; |
|
flex-direction: column; |
|
align-items: center; |
|
min-height: 100vh; |
|
} |
|
|
|
h1 { |
|
color: #ff9f43; |
|
margin-bottom: 10px; |
|
} |
|
|
|
.container { |
|
max-width: 800px; |
|
width: 100%; |
|
} |
|
|
|
#status { |
|
margin: 20px 0; |
|
padding: 10px; |
|
background-color: #2d2d2d; |
|
border-radius: 5px; |
|
font-family: monospace; |
|
} |
|
|
|
.canvas-container { |
|
margin: 20px 0; |
|
border: 2px solid #ff9f43; |
|
border-radius: 5px; |
|
background-color: #000; |
|
display: inline-block; |
|
} |
|
|
|
#canvas { |
|
display: block; |
|
image-rendering: pixelated; |
|
image-rendering: crisp-edges; |
|
} |
|
|
|
.controls { |
|
margin: 20px 0; |
|
} |
|
|
|
button { |
|
background-color: #ff9f43; |
|
color: #1e1e1e; |
|
border: none; |
|
padding: 10px 20px; |
|
margin: 5px; |
|
border-radius: 5px; |
|
cursor: pointer; |
|
font-size: 16px; |
|
font-weight: bold; |
|
} |
|
|
|
button:hover { |
|
background-color: #ffb366; |
|
} |
|
|
|
button:disabled { |
|
background-color: #666; |
|
cursor: not-allowed; |
|
} |
|
|
|
.info { |
|
background-color: #2d2d2d; |
|
padding: 15px; |
|
border-radius: 5px; |
|
margin: 20px 0; |
|
} |
|
|
|
.info h2 { |
|
color: #ff9f43; |
|
margin-top: 0; |
|
} |
|
|
|
code { |
|
background-color: #1e1e1e; |
|
padding: 2px 6px; |
|
border-radius: 3px; |
|
font-family: 'Courier New', monospace; |
|
} |
|
/* Game-specific layout */ |
|
.game-layout { |
|
display: flex; |
|
gap: 20px; |
|
align-items: flex-start; |
|
} |
|
.game-layout .left { |
|
flex: 1 1 640px; |
|
display: flex; |
|
flex-direction: column; |
|
align-items: center; |
|
} |
|
.game-layout .right { |
|
width: 320px; |
|
flex: 0 0 320px; |
|
} |
|
.hud { |
|
margin-top: 8px; |
|
background: rgba(0,0,0,0.6); |
|
padding: 6px 10px; |
|
border-radius: 4px; |
|
color: #dcdcdc; |
|
font-family: monospace; |
|
} |
|
.control-row { margin-top: 10px; display:flex; gap:8px; align-items:center; } |
|
.control-row label { min-width: 70px; font-size:0.95em; } |
|
.controls { display:flex; flex-wrap:wrap; gap:8px; } |
|
.progress { margin-top: 12px; } |
|
.help { margin-top: 12px; font-size:0.95em; } |
|
input[type="range"] { width: 100%; accent-color: #ff9f43; } |
|
progress { width: 100%; accent-color: #ff9f43; height: 8px; } |
|
select { background-color: #2d2d2d; color: #fff; border: 1px solid #555; padding: 6px; border-radius: 3px; } |
|
details summary { cursor: pointer; color: #ff9f43; font-weight: bold; } |
|
details summary:hover { color: #ffb366; } |
|
</style> |
|
</head> |
|
<body> |
|
<div class="container"> |
|
<h1>🐭 Mice! - The Game</h1> |
|
<p><strong>Eliminate the rats before they multiply!</strong> A strategic maze game with bombs, mines, and gas.</p> |
|
<p style="font-size:0.9em;color:#aaa;">Browser Edition powered by Python + Pygame via Pyodide</p> |
|
|
|
<div id="status">Loading Pyodide...</div> |
|
|
|
<!-- Profile Creation Modal --> |
|
<div id="profileModal" title="Create New Profile"> |
|
<form id="profileForm"> |
|
<div style="margin-bottom:15px;"> |
|
<label for="profileName">Player Name:</label><br> |
|
<input type="text" id="profileName" name="profileName" placeholder="Enter your name" style="width:100%;padding:8px;margin-top:5px;border:1px solid #555;background:#2d2d2d;color:#fff;border-radius:3px;" required> |
|
</div> |
|
<div style="margin-bottom:15px;"> |
|
<label for="difficulty">Difficulty:</label><br> |
|
<select id="difficulty" name="difficulty" style="width:100%;padding:8px;margin-top:5px;border:1px solid #555;background:#2d2d2d;color:#fff;border-radius:3px;"> |
|
<option value="normal">Normal</option> |
|
<option value="hard">Hard</option> |
|
<option value="expert">Expert</option> |
|
</select> |
|
</div> |
|
<div style="margin-bottom:15px;"> |
|
<label> |
|
<input type="checkbox" id="soundEnabled" name="soundEnabled" checked> Enable Sound |
|
</label> |
|
</div> |
|
</form> |
|
</div> |
|
|
|
<div class="game-layout"> |
|
<div class="left"> |
|
<div class="canvas-container"> |
|
<canvas id="canvas" width="640" height="480" aria-label="Mice game canvas"></canvas> |
|
</div> |
|
<div id="hud" class="hud">Status: <span id="hud-status">Idle</span></div> |
|
</div> |
|
|
|
<div class="right"> |
|
<div class="controls"> |
|
<button id="startBtn" disabled>Start Game</button> |
|
<button id="stopBtn" disabled>Stop Game</button> |
|
<button id="fullscreenBtn">Fullscreen</button> |
|
<button id="muteBtn">Mute</button> |
|
</div> |
|
|
|
<div class="control-row"> |
|
<label for="volumeRange">Volume</label> |
|
<input id="volumeRange" type="range" min="0" max="100" value="100"> |
|
</div> |
|
|
|
<div class="control-row"> |
|
<label for="profileSelect">Profile</label> |
|
<select id="profileSelect"><option value="">(None)</option></select> |
|
<button id="loadProfileBtn">Load</button> |
|
</div> |
|
|
|
<div class="control-row"> |
|
<button id="newProfileBtn" style="flex:1;">+ New Profile</button> |
|
</div> |
|
|
|
<div class="progress"> |
|
<label>Asset load progress</label> |
|
<progress id="assetProgress" value="0" max="100"></progress> |
|
<div id="assetProgressText">0 / 0</div> |
|
</div> |
|
|
|
<details> |
|
<summary>Advanced</summary> |
|
<div style="margin-top:8px;"> |
|
<button id="runDemoBtn">Run Pygame Demo</button> |
|
<div style="margin-top:8px;font-size:0.9em;color:#ddd;">Useful for testing the canvas rendering when debugging.</div> |
|
</div> |
|
</details> |
|
|
|
<div class="help"> |
|
<h3>Controls</h3> |
|
<ul> |
|
<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> |
|
|
|
<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> |
|
|
|
<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 src="https://code.jquery.com/jquery-3.6.0.min.js"></script> |
|
<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/dark/jquery-ui.css"> |
|
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.min.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'); |
|
|
|
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, totalCount } = await loadProjectFiles(); |
|
|
|
// Setup asset progress UI |
|
const assetProgress = document.getElementById('assetProgress'); |
|
const assetProgressText = document.getElementById('assetProgressText'); |
|
let writtenCount = 0; |
|
assetProgress.max = totalCount || 1; |
|
function incrementProgress() { |
|
writtenCount += 1; |
|
assetProgress.value = writtenCount; |
|
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; |
|
|
|
// 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 + modal |
|
function initProfileModal() { |
|
// Initialize jQuery UI modal dialog |
|
$('#profileModal').dialog({ |
|
autoOpen: false, |
|
modal: true, |
|
buttons: { |
|
'Create Profile': function() { |
|
saveProfile(); |
|
$(this).dialog('close'); |
|
}, |
|
'Cancel': function() { |
|
$(this).dialog('close'); |
|
} |
|
}, |
|
dialogClass: 'profile-dialog' |
|
}); |
|
|
|
// Add CSS for dark theme dialog |
|
$('<style>') |
|
.text(` |
|
.ui-dialog { background-color: #2d2d2d !important; } |
|
.ui-dialog-titlebar { background-color: #1e1e1e !important; } |
|
.ui-dialog-title { color: #ff9f43 !important; } |
|
.ui-button { background-color: #ff9f43 !important; color: #1e1e1e !important; } |
|
.ui-button:hover { background-color: #ffb366 !important; } |
|
`) |
|
.appendTo('head'); |
|
} |
|
|
|
function saveProfile() { |
|
const name = $('#profileName').val(); |
|
const difficulty = $('#difficulty').val(); |
|
const soundEnabled = $('#soundEnabled').is(':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 |
|
$('#profileForm')[0].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').dialog('open'); |
|
}); |
|
|
|
// 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', () => { |
|
$(document).ready(() => { |
|
initProfileModal(); |
|
populateProfileDropdown(); |
|
}); |
|
loadPyodideAndSetup().catch(err => { |
|
console.error('Failed to initialize Pyodide:', err); |
|
updateStatus('Failed to load Pyodide: ' + err.message); |
|
}); |
|
}); |
|
</script> |
|
</body> |
|
</html>
|
|
|