1. Race condition: session_end now waits for an in-flight session_start promise before sending, so quick restarts don't drop the end call. 2. beforeunload: replaced sendBeacon (which can't set headers) with fetch(..., keepalive: true) so the X-API-Key header is included and the backend doesn't 401. 3. Stats double-counting: removed stats_record_damage_dealt and stats_record_kill from damage_entity (which was called for all damage including player deaths). Now only recorded at player-sourced call sites (projectile hits, stomps). 4. Removed const-cast: analytics_session_end now takes GameStats* (non-const) since stats_update_score mutates it. 5. beforeunload now uses stashed stats from the last C-side session_end call instead of hardcoded zeroes. Session ID is cleared synchronously before async fetch to prevent races. 6. Removed unused stdint.h include from stats.h.
322 lines
10 KiB
HTML
322 lines
10 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Jump 'n Run</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
background: #1a1a2e;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 100vh;
|
|
font-family: monospace;
|
|
color: #e0e0e0;
|
|
}
|
|
#canvas-container {
|
|
position: relative;
|
|
border: 2px solid #333;
|
|
background: #000;
|
|
}
|
|
canvas.emscripten {
|
|
display: block;
|
|
width: 1280px;
|
|
height: 720px;
|
|
image-rendering: pixelated;
|
|
image-rendering: crisp-edges;
|
|
}
|
|
#status {
|
|
margin-top: 12px;
|
|
font-size: 14px;
|
|
color: #888;
|
|
}
|
|
#progress-bar {
|
|
width: 320px;
|
|
height: 6px;
|
|
background: #333;
|
|
border-radius: 3px;
|
|
margin-top: 8px;
|
|
overflow: hidden;
|
|
}
|
|
#progress-bar-inner {
|
|
height: 100%;
|
|
width: 0%;
|
|
background: #4ecdc4;
|
|
transition: width 0.2s;
|
|
}
|
|
.hidden { display: none !important; }
|
|
|
|
#controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 8px;
|
|
font-size: 12px;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
max-width: 1300px;
|
|
}
|
|
#hint { color: #666; }
|
|
#game-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 8px;
|
|
font-size: 12px;
|
|
color: #666;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
max-width: 1300px;
|
|
}
|
|
.ctrl-btn {
|
|
color: #4ecdc4;
|
|
background: transparent;
|
|
border: 1px solid #4ecdc4;
|
|
padding: 2px 8px;
|
|
border-radius: 3px;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
}
|
|
.ctrl-btn:hover {
|
|
background: #4ecdc4;
|
|
color: #1a1a2e;
|
|
}
|
|
#level-select {
|
|
background: #1a1a2e;
|
|
color: #4ecdc4;
|
|
border: 1px solid #4ecdc4;
|
|
padding: 2px 4px;
|
|
border-radius: 3px;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
}
|
|
#level-select option {
|
|
background: #1a1a2e;
|
|
color: #e0e0e0;
|
|
}
|
|
.ctrl-sep {
|
|
color: #333;
|
|
user-select: none;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="canvas-container"
|
|
data-analytics-url=""
|
|
data-analytics-key="">
|
|
<canvas class="emscripten" id="canvas" tabindex="1"
|
|
width="640" height="360"></canvas>
|
|
</div>
|
|
<div id="controls" class="hidden">
|
|
<a class="ctrl-btn" id="editor-link" href="?edit">Editor</a>
|
|
<span class="ctrl-sep">|</span>
|
|
<button class="ctrl-btn" id="btn-save" title="Save level (download .lvl)">Save</button>
|
|
<button class="ctrl-btn" id="btn-load" title="Load .lvl from disk">Load</button>
|
|
<span class="ctrl-sep">|</span>
|
|
<select id="level-select" title="Load a built-in level">
|
|
<option value="">-- Open level --</option>
|
|
</select>
|
|
<span class="ctrl-sep">|</span>
|
|
<span id="hint">E=editor P=test play 1-6=tools</span>
|
|
</div>
|
|
<div id="game-controls">
|
|
<span>Arrows=move</span>
|
|
<span class="ctrl-sep">|</span>
|
|
<span>Z/Space=jump</span>
|
|
<span class="ctrl-sep">|</span>
|
|
<span>X=shoot</span>
|
|
<span class="ctrl-sep">|</span>
|
|
<span>C=dash</span>
|
|
<span class="ctrl-sep">|</span>
|
|
<span>Up=aim up</span>
|
|
</div>
|
|
<div id="status">Loading...</div>
|
|
<div id="progress-bar"><div id="progress-bar-inner"></div></div>
|
|
|
|
<script>
|
|
/* ── Editor gate: only enable when localStorage show_editor=true ── */
|
|
var editorEnabled = false;
|
|
try { editorEnabled = (localStorage.getItem('show_editor') === 'true'); } catch(e) {}
|
|
if (editorEnabled) {
|
|
document.getElementById('controls').classList.remove('hidden');
|
|
document.getElementById('game-controls').classList.add('hidden');
|
|
}
|
|
/* Strip ?edit from URL when editor is not enabled */
|
|
if (!editorEnabled && window.location.search.indexOf('edit') !== -1) {
|
|
window.history.replaceState({}, '', window.location.pathname);
|
|
}
|
|
|
|
var statusEl = document.getElementById('status');
|
|
var progressEl = document.getElementById('progress-bar');
|
|
var progressIn = document.getElementById('progress-bar-inner');
|
|
|
|
var Module = {
|
|
canvas: document.getElementById('canvas'),
|
|
/* Force 1:1 pixel mapping so SDL_RenderSetLogicalSize doesn't
|
|
compute a viewport offset on HiDPI / fractional-scale displays */
|
|
devicePixelRatio: 1,
|
|
print: function(text) { console.log(text); },
|
|
printErr: function(text) { console.error(text); },
|
|
setStatus: function(text) {
|
|
if (!text) {
|
|
statusEl.classList.add('hidden');
|
|
progressEl.classList.add('hidden');
|
|
return;
|
|
}
|
|
/* Parse "Downloading data... (X/Y)" progress messages */
|
|
var m = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/);
|
|
if (m) {
|
|
var pct = (parseInt(m[2]) / parseInt(m[4])) * 100;
|
|
progressIn.style.width = pct + '%';
|
|
}
|
|
statusEl.textContent = text;
|
|
},
|
|
totalDependencies: 0,
|
|
monitorRunDependencies: function(left) {
|
|
this.totalDependencies = Math.max(this.totalDependencies, left);
|
|
Module.setStatus(left
|
|
? 'Loading... (' + (this.totalDependencies - left) + '/' + this.totalDependencies + ')'
|
|
: '');
|
|
}
|
|
};
|
|
|
|
Module.setStatus('Loading...');
|
|
window.onerror = function() {
|
|
Module.setStatus('Error - check the browser console');
|
|
};
|
|
|
|
/* ── Keyboard shortcuts: Ctrl+S / Ctrl+O / E-key ── */
|
|
document.addEventListener('keydown', function(e) {
|
|
/* Block E-key editor entry when editor is not enabled */
|
|
if (!editorEnabled && e.key === 'e' && !e.ctrlKey && !e.metaKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
return;
|
|
}
|
|
if (editorEnabled && (e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (typeof _editor_save_flag_ptr === 'function') {
|
|
HEAP32[_editor_save_flag_ptr() >> 2] = 1;
|
|
}
|
|
}
|
|
if (editorEnabled && (e.ctrlKey || e.metaKey) && e.key === 'o') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (typeof _editor_load_flag_ptr === 'function') {
|
|
HEAP32[_editor_load_flag_ptr() >> 2] = 1;
|
|
}
|
|
}
|
|
}, true); /* useCapture=true to intercept before SDL */
|
|
|
|
/* ── Editor-only features (button handlers, level picker) ── */
|
|
if (editorEnabled) {
|
|
document.getElementById('btn-save').addEventListener('click', function() {
|
|
if (typeof _editor_save_flag_ptr === 'function') {
|
|
HEAP32[_editor_save_flag_ptr() >> 2] = 1;
|
|
}
|
|
/* Return focus to canvas so keys keep working */
|
|
document.getElementById('canvas').focus();
|
|
});
|
|
|
|
document.getElementById('btn-load').addEventListener('click', function() {
|
|
if (typeof _editor_load_flag_ptr === 'function') {
|
|
HEAP32[_editor_load_flag_ptr() >> 2] = 1;
|
|
}
|
|
document.getElementById('canvas').focus();
|
|
});
|
|
|
|
/* ── Level picker dropdown ──────────────────────── */
|
|
var levelSelect = document.getElementById('level-select');
|
|
|
|
/* Populate the dropdown after the module is ready (FS available) */
|
|
Module.postRun = Module.postRun || [];
|
|
Module.postRun.push(function() {
|
|
/* Scan for .lvl files in the virtual FS */
|
|
var levels = [];
|
|
try {
|
|
var entries = FS.readdir('assets/levels');
|
|
for (var i = 0; i < entries.length; i++) {
|
|
if (entries[i].endsWith('.lvl') && entries[i][0] !== '_') {
|
|
levels.push('assets/levels/' + entries[i]);
|
|
}
|
|
}
|
|
levels.sort();
|
|
} catch(e) {
|
|
console.error('Could not read levels dir:', e);
|
|
}
|
|
|
|
for (var j = 0; j < levels.length; j++) {
|
|
var opt = document.createElement('option');
|
|
opt.value = levels[j];
|
|
/* Show just the filename without path */
|
|
opt.textContent = levels[j].replace('assets/levels/', '');
|
|
levelSelect.appendChild(opt);
|
|
}
|
|
});
|
|
|
|
levelSelect.addEventListener('change', function() {
|
|
var path = this.value;
|
|
if (!path) return;
|
|
|
|
/* Load the level into gameplay (MODE_PLAY) via main.c */
|
|
if (typeof _game_load_level === 'function') {
|
|
var len = lengthBytesUTF8(path) + 1;
|
|
var buf = _malloc(len);
|
|
stringToUTF8(path, buf, len);
|
|
_game_load_level(buf);
|
|
_free(buf);
|
|
}
|
|
|
|
/* Reset dropdown to placeholder */
|
|
this.selectedIndex = 0;
|
|
document.getElementById('canvas').focus();
|
|
});
|
|
|
|
/* Update title if in editor mode */
|
|
if (window.location.search.indexOf('edit') !== -1) {
|
|
document.title = 'Jump \'n Run - Level Editor';
|
|
}
|
|
}
|
|
|
|
/* ── Analytics: end session on tab close ────────────── */
|
|
/* Fallback for when the WASM shutdown path didn't get a chance to
|
|
* run (e.g. user closes tab mid-game). Uses fetch with keepalive
|
|
* so the browser can send the request after the page is gone, and
|
|
* includes the X-API-Key header that sendBeacon can't carry. */
|
|
window.addEventListener('beforeunload', function() {
|
|
if (typeof Module !== 'undefined' && Module._analyticsSessionId &&
|
|
Module._analyticsUrl) {
|
|
var sid = Module._analyticsSessionId;
|
|
Module._analyticsSessionId = null;
|
|
/* Use stashed stats from the last C-side update if available,
|
|
* otherwise send minimal data so the session isn't left open. */
|
|
var body = Module._analyticsLastStats || JSON.stringify({
|
|
score: 0,
|
|
level_reached: 1,
|
|
lives_used: 0,
|
|
duration_seconds: 0,
|
|
end_reason: 'quit'
|
|
});
|
|
fetch(Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-API-Key': Module._analyticsKey
|
|
},
|
|
body: body,
|
|
keepalive: true
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
{{{ SCRIPT }}}
|
|
</body>
|
|
</html>
|