4 changed files with 1108 additions and 21 deletions
|
After Width: | Height: | Size: 235 B |
@ -0,0 +1,966 @@
|
||||
<!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> |
||||
@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3 |
||||
"""Create a favicon.ico from an existing PNG asset. |
||||
|
||||
Usage: python tools/create_favicon.py |
||||
|
||||
Produces: ./favicon.ico |
||||
|
||||
The script will try to import Pillow. If it's not available, it prints instructions. |
||||
""" |
||||
import os |
||||
import sys |
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(__file__)) |
||||
SRC = os.path.join(ROOT, 'assets', 'BMP_WEWIN.png') |
||||
# fallback to assets/Rat/BMP_WEWIN.png if present |
||||
if not os.path.exists(SRC): |
||||
alt = os.path.join(ROOT, 'assets', 'Rat', 'BMP_WEWIN.png') |
||||
if os.path.exists(alt): |
||||
SRC = alt |
||||
OUT = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'favicon.ico') |
||||
|
||||
|
||||
def ensure_pillow(): |
||||
try: |
||||
from PIL import Image |
||||
return Image |
||||
except Exception: |
||||
print('Pillow (PIL) is required to run this script. Install with:') |
||||
print(' pip install Pillow') |
||||
return None |
||||
|
||||
|
||||
def create_favicon(): |
||||
Image = ensure_pillow() |
||||
if Image is None: |
||||
return 2 |
||||
|
||||
if not os.path.exists(SRC): |
||||
print(f'Source PNG not found: {SRC}') |
||||
return 1 |
||||
|
||||
try: |
||||
img = Image.open(SRC) |
||||
# Create multiple sizes for favicon |
||||
sizes = [(16, 16), (32, 32), (48, 48), (64, 64)] |
||||
icons = [img.resize(s, Image.Resampling.LANCZOS) if hasattr(Image, 'Resampling') else img.resize(s) for s in sizes] |
||||
# Save as ICO |
||||
icons[0].save(OUT, format='ICO', sizes=sizes) |
||||
print(f'Created favicon: {OUT}') |
||||
return 0 |
||||
except Exception as e: |
||||
print('Failed to create favicon:', e) |
||||
return 3 |
||||
|
||||
|
||||
if __name__ == '__main__': |
||||
sys.exit(create_favicon()) |
||||
Loading…
Reference in new issue