0d765961af
- Interaktionen aus gleich-origin iFrames (Heli-Start/-Flug/-Landung) zaehlen jetzt
(periodisches _hookFrame), plus postMessage {type:'ggs-activity'} + __GGS_LIVE__.ping().
Aktives Steuern gilt nicht mehr faelschlich als idle.
- EVENTS erweitert (keyup/touchmove/pointermove). Heli in IDLE_PAUSE_SIMS aufgenommen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
294 lines
15 KiB
JavaScript
294 lines
15 KiB
JavaScript
/**
|
||
* GeoGraSim Plattform: Live-Client
|
||
*
|
||
* Sendet Heartbeats an /api/live (Live-View für Lehrkräfte) und pollt das
|
||
* Pause-Flag aus /api/modules?student=1. Wird vom Sim-Wrapper als Plattform-
|
||
* Asset geladen — Sims selbst müssen nichts implementieren.
|
||
*
|
||
* Erwartet: window.__GGS__ = { sessionId, simId, baseUrl, basePath, apiUrl }.
|
||
*
|
||
* Sims können optional window.GGS_LIVE_STATE() zurückgeben, um einen kleinen
|
||
* State-Snapshot mitzusenden (max 50 KB). Ohne diese Funktion wird nur ein
|
||
* leerer State gemeldet — Lehrer sieht trotzdem WER online ist und WAS spielt.
|
||
*
|
||
* ──────────────────────────────────────────────────────────────────────────
|
||
* Plattform-Conventions, die Sims optional registrieren können:
|
||
*
|
||
* 1. window.GGS_LIVE_STATE = function() { return {…}; }
|
||
* Read-only Snapshot des aktuellen Sim-Zustands; wird in jeden Heartbeat
|
||
* gepackt und im Lehrer-Cockpit visualisiert.
|
||
*
|
||
* 2. window.GGS_TUTORIAL = { simId, title, replay: function() {…} }
|
||
* Onboarding-Replay-Hook. Der ℹ-Button im Header jeder Sim ruft
|
||
* GGS_TUTORIAL.replay() auf, damit Lernende die Anleitung jederzeit
|
||
* nochmal aufrufen können. Konvention für alle Sim-Header: ein
|
||
* <button class="ggs-btn-ghost" title="Anleitung nochmal zeigen">ℹ</button>
|
||
* in .ggs-header-actions, der das Replay triggert.
|
||
*
|
||
* 3. window.__GGS_LIVE__ (vom Plattform-Client gesetzt, read-only)
|
||
* Sims, die ihre eigene Spielzeit messen wollen, sollten activeMs/idleMs
|
||
* statt Date.now() - startTs lesen — sonst zählen Pausen/Idle mit.
|
||
* ──────────────────────────────────────────────────────────────────────────
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
if (window.__GGS_LIVE_INITIALIZED__) return;
|
||
window.__GGS_LIVE_INITIALIZED__ = true;
|
||
|
||
var ctx = window.__GGS__ || {};
|
||
// Ohne simId können wir nicht melden, welches Modul gespielt wird.
|
||
if (!ctx.simId) return;
|
||
// sessionId nicht vorab prüfen — der Browser sendet das ggs_session-Cookie
|
||
// automatisch mit den fetch-Calls (credentials:'same-origin'), und der
|
||
// Server entscheidet, ob es eine gültige Schüler-Session ist.
|
||
var apiBase = ctx.apiUrl || ((ctx.baseUrl || '') + '/php/api');
|
||
var simId = ctx.simId;
|
||
|
||
// --- Aktivitäts-/Idle-Tracking ---------------------------------------------
|
||
// Pause-Logik: aktive Spielzeit zählt NUR weiter, solange die letzte
|
||
// Nutzer:innen-Interaktion ≤ IDLE_THRESHOLD_MS her ist. Sonst gilt die
|
||
// Sitzung als „idle" und die Zeit-Akkumulation stoppt — verhindert
|
||
// absurd lange Sessions in der DB (Tab über Nacht offen, Wegfall ohne
|
||
// Tab-Schließung, etc.).
|
||
// Hard-Cap: 120 min pro Sim-Lauf. Frontend stoppt von selbst,
|
||
// Backend (assessment.php/progress.php) capped zusätzlich defensiv.
|
||
// Großzügig (5 min): Lesen und Nachdenken sollen NICHT als „weg" gelten.
|
||
// Scrollen/Mausbewegung zählen ohnehin als Interaktion (siehe EVENTS), und
|
||
// echtes Weg-Sein (anderer Tab) wird über die Tab-Sichtbarkeit ausgeblendet.
|
||
var IDLE_THRESHOLD_MS = 300000; // 5 min ohne jede Regung → idle (Backstop)
|
||
var IDLE_PAUSE_MS = 180000; // 3 min → sichtbare Pause mit „Weiter"-Button
|
||
// Sichtbare Idle-Pause vorerst NUR dort, wo sie getestet ist. Sonst stört sie
|
||
// passive Phasen (z. B. die Heli-Flug-Animation, wo man minutenlang nur zuschaut).
|
||
// Freischalten pro App: Sim hier eintragen ODER window.GGS_IDLE_PAUSE=true setzen.
|
||
var IDLE_PAUSE_SIMS = ['klima', 'klima-3d', 'heli'];
|
||
var IDLE_PAUSE_ENABLED = IDLE_PAUSE_SIMS.indexOf(simId) !== -1 || window.GGS_IDLE_PAUSE === true;
|
||
var MAX_SESSION_MS = 7200000; // 120 min — Plattform-Hardcap
|
||
var TICK_MS = 1000; // Auflösung der activeMs-Akkumulation
|
||
var sessionStartedAt = Date.now();
|
||
var lastInteractionAt = Date.now();
|
||
var activeMs = 0; // akkumuliert, NUR wenn nicht idle
|
||
var lastTickAt = Date.now();
|
||
var sessionCapped = false;
|
||
var paused = false; // Sim-Pause: friert activeMs ein (opt-in)
|
||
var idleActive = false; // sichtbare Idle-Pause aktiv (Weiter-Overlay)
|
||
|
||
function onInteraction() {
|
||
lastInteractionAt = Date.now();
|
||
}
|
||
// Passive Listener, damit Scrollen/Touch nicht ausgebremst wird.
|
||
var EVENTS = ['mousemove', 'mousedown', 'keydown', 'keyup', 'touchstart', 'touchmove', 'wheel', 'click', 'pointerdown', 'pointermove'];
|
||
EVENTS.forEach(function (ev) {
|
||
window.addEventListener(ev, onInteraction, { passive: true, capture: true });
|
||
});
|
||
|
||
// Interaktionen auch aus (gleich-origin) iFrames erfassen — sonst gilt aktives
|
||
// Steuern in einem Sub-iFrame (z. B. Heli-Start/-Flug/-Landung) fälschlich als
|
||
// idle, obwohl die Schüler:in gerade lenkt. iFrames werden dynamisch geladen/
|
||
// getauscht → periodisch nachhängen. Cross-origin wirft → still ignorieren.
|
||
function _hookFrame(doc) {
|
||
if (!doc || doc.__ggsActivityHooked) return;
|
||
doc.__ggsActivityHooked = true;
|
||
EVENTS.forEach(function (ev) {
|
||
try { doc.addEventListener(ev, onInteraction, { passive: true, capture: true }); } catch (_) {}
|
||
});
|
||
}
|
||
function _scanFrames() {
|
||
var frames = document.getElementsByTagName('iframe');
|
||
for (var i = 0; i < frames.length; i++) {
|
||
try { _hookFrame(frames[i].contentDocument); } catch (_) { /* cross-origin */ }
|
||
}
|
||
}
|
||
setInterval(_scanFrames, 2500);
|
||
// Fallback: Sims/iFrames können aktives Arbeiten explizit signalisieren.
|
||
window.addEventListener('message', function (e) {
|
||
if (e && e.data && e.data.type === 'ggs-activity') onInteraction();
|
||
});
|
||
|
||
// Tick-Loop für activeMs-Akkumulation. Läuft auch wenn Sim nichts tut,
|
||
// hält die Buchhaltung sauber. Inkrementiert in Echtzeit-Schritten, deckt
|
||
// damit auch große Lücken (Tab-Sleep) korrekt ab.
|
||
function tick() {
|
||
var now = Date.now();
|
||
var delta = now - lastTickAt;
|
||
lastTickAt = now;
|
||
// Pausiert (Sim-setPaused ODER sichtbare Idle-Pause) → Zeit steht, nicht zählen.
|
||
// lastTickAt ist schon aktualisiert, daher springt activeMs beim Fortsetzen nicht.
|
||
if (paused || idleActive) return;
|
||
// Tab nicht sichtbar (anderer Tab, minimiert) → echtes Weg-Sein, nicht zählen.
|
||
// Denkpausen bei SICHTBAREM Tab bleiben über das großzügige Idle-Fenster erhalten.
|
||
if (typeof document !== 'undefined' && document.hidden) return;
|
||
// Plausibilität: bei Tab-Sleep kann delta riesig sein → max einen Tick
|
||
// anrechnen, der Rest war definitiv nicht aktive Spielzeit.
|
||
if (delta > TICK_MS * 3) delta = 0;
|
||
var idleFor = now - lastInteractionAt;
|
||
// Nach 3 min Untätigkeit: sichtbare Pause einblenden (friert die Zeit ein,
|
||
// damit Idle nicht als Arbeitszeit zählt). Nicht im Spectator-Modus und
|
||
// nicht, während die Lehrperson ohnehin pausiert hat.
|
||
if (IDLE_PAUSE_ENABLED && idleFor >= IDLE_PAUSE_MS && !isSpectator()
|
||
&& !(pauseOverlay && pauseOverlay.style.display !== 'none')) {
|
||
showIdlePause();
|
||
return;
|
||
}
|
||
if (idleFor < IDLE_THRESHOLD_MS) {
|
||
activeMs += delta;
|
||
if (activeMs >= MAX_SESSION_MS && !sessionCapped) {
|
||
activeMs = MAX_SESSION_MS;
|
||
sessionCapped = true;
|
||
// Sichtbarer Hinweis falls jemand 2h+ am Stück arbeitet
|
||
try { console.warn('[GGS] Session-Cap 120 min erreicht.'); } catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
setInterval(tick, TICK_MS);
|
||
|
||
// Read-only Anbindung für Sims, die ihre eigene Dauer rechnen wollen.
|
||
// Sims sollten window.__GGS_LIVE__.activeMs lesen statt Date.now() - start.
|
||
window.__GGS_LIVE__ = Object.freeze({
|
||
get activeMs() { return activeMs; },
|
||
get idleMs() { return Date.now() - lastInteractionAt; },
|
||
get sessionStartedAt() { return sessionStartedAt; },
|
||
get lastInteractionAt() { return lastInteractionAt; },
|
||
get capped() { return sessionCapped; },
|
||
get paused() { return paused; },
|
||
// Sim ruft dies bei Pause/Fortsetzen — friert die Aktivzeit-Buchhaltung ein.
|
||
setPaused: function (v) { paused = !!v; },
|
||
// Sim/iFrame kann aktives Arbeiten signalisieren (setzt den Idle-Zähler zurück).
|
||
ping: function () { onInteraction(); },
|
||
MAX_SESSION_MS: MAX_SESSION_MS,
|
||
IDLE_THRESHOLD_MS: IDLE_THRESHOLD_MS,
|
||
});
|
||
|
||
// --- Heartbeat ---
|
||
var heartbeatTimer = null;
|
||
function snapshotState() {
|
||
try {
|
||
if (typeof window.GGS_LIVE_STATE === 'function') {
|
||
var s = window.GGS_LIVE_STATE();
|
||
if (s && typeof s === 'object') return s;
|
||
}
|
||
} catch (_) {}
|
||
return {};
|
||
}
|
||
function sendHeartbeat() {
|
||
var state = snapshotState();
|
||
// Aktivitätsfelder generisch dazumischen — werden im Lehrer-Cockpit als
|
||
// status='idle' bzw. activeSeconds ausgewertet (ohne dass die Sim was tun muss).
|
||
state.__activeMs = activeMs;
|
||
state.__idleMs = Date.now() - lastInteractionAt;
|
||
state.__lastInteractionAt = lastInteractionAt;
|
||
state.__sessionStartedAt = sessionStartedAt;
|
||
state.__capped = sessionCapped;
|
||
state.__paused = paused || idleActive;
|
||
state.__idlePaused = idleActive;
|
||
fetch(apiBase + '/live.php', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({ action: 'heartbeat', module_id: simId, state: state })
|
||
}).catch(function () {});
|
||
}
|
||
|
||
// --- Pause-Polling ---
|
||
var pauseTimer = null;
|
||
var pauseOverlay = null;
|
||
function ensureOverlay() {
|
||
if (pauseOverlay) return pauseOverlay;
|
||
var el = document.createElement('div');
|
||
el.id = 'ggs-pause-overlay';
|
||
el.style.cssText = 'position:fixed;inset:0;background:rgba(31,75,55,.78);color:#fff;z-index:99999;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:Inter,sans-serif;font-weight:700;text-align:center;padding:2rem;backdrop-filter:blur(3px)';
|
||
el.innerHTML = '<div style="font-size:4rem;margin-bottom:.6rem">⏸</div>'
|
||
+ '<div style="font-size:1.6rem;margin-bottom:.4rem">Auftrag pausiert</div>'
|
||
+ '<div style="font-size:.95rem;font-weight:500;opacity:.85;max-width:420px;line-height:1.4">Deine Lehrperson hat das Tempo angehalten. Sobald sie freigibt, kannst du weitermachen.</div>';
|
||
document.body.appendChild(el);
|
||
pauseOverlay = el;
|
||
return el;
|
||
}
|
||
function showPause() { ensureOverlay().style.display = 'flex'; }
|
||
function hidePause() { if (pauseOverlay) pauseOverlay.style.display = 'none'; }
|
||
function checkPause() {
|
||
fetch(apiBase + '/modules.php?student=1&module_id=' + encodeURIComponent(simId), {
|
||
credentials: 'same-origin'
|
||
}).then(function (r) { return r.json(); })
|
||
.then(function (m) {
|
||
if (m && m.paused && m.mode === 'teacher_started') showPause();
|
||
else hidePause();
|
||
})
|
||
.catch(function () {});
|
||
}
|
||
|
||
// --- Idle-Pause (plattformweit, für JEDE Sim) ---
|
||
// Nach längerer Untätigkeit blenden wir eine freundliche Pause mit „Weiter"-
|
||
// Button ein. Solange sie sichtbar ist, steht die Aktivzeit (idleActive) —
|
||
// die Pause zählt also NICHT als Arbeitszeit. Der Lernende kann jederzeit
|
||
// per Klick weitermachen. Kein Sim-Code nötig (dieses Asset lädt überall).
|
||
var idleOverlay = null;
|
||
function ensureIdleOverlay() {
|
||
if (idleOverlay) return idleOverlay;
|
||
var el = document.createElement('div');
|
||
el.id = 'ggs-idle-overlay';
|
||
el.style.cssText = 'position:fixed;inset:0;background:rgba(31,75,55,.82);color:#fff;z-index:99999;display:none;flex-direction:column;align-items:center;justify-content:center;font-family:Inter,-apple-system,system-ui,sans-serif;text-align:center;padding:2rem;backdrop-filter:blur(3px);cursor:pointer';
|
||
el.innerHTML = '<div style="font-size:3.6rem;margin-bottom:.6rem">😴</div>'
|
||
+ '<div style="font-size:1.55rem;font-weight:700;margin-bottom:.5rem">Kurze Pause</div>'
|
||
+ '<div style="font-size:1rem;font-weight:500;opacity:.9;max-width:440px;line-height:1.45;margin-bottom:1.5rem">Du warst eine Weile ganz ruhig — ich habe die Zeit angehalten, damit die Pause nicht als Arbeitszeit zählt. Klick auf „Weiter", wenn du weitermachen möchtest.</div>'
|
||
+ '<button id="ggs-idle-resume" style="font-size:1.05rem;font-weight:700;padding:.75rem 2.2rem;border:none;border-radius:10px;background:#fff;color:#1f4b37;cursor:pointer;box-shadow:0 3px 10px rgba(0,0,0,.25)">Weiter</button>';
|
||
document.body.appendChild(el);
|
||
el.addEventListener('click', resumeFromIdle);
|
||
idleOverlay = el;
|
||
return el;
|
||
}
|
||
function showIdlePause() {
|
||
if (idleActive) return;
|
||
idleActive = true;
|
||
ensureIdleOverlay().style.display = 'flex';
|
||
}
|
||
function resumeFromIdle() {
|
||
idleActive = false;
|
||
lastInteractionAt = Date.now();
|
||
lastTickAt = Date.now(); // kein Zeitsprung beim Fortsetzen
|
||
if (idleOverlay) idleOverlay.style.display = 'none';
|
||
}
|
||
|
||
// --- Spectator-Mode für Lehrkräfte ---
|
||
// ?view=teacher schaltet Input ab und blendet ein dezentes Banner ein.
|
||
function isSpectator() {
|
||
try { return new URLSearchParams(location.search).get('view') === 'teacher'; }
|
||
catch (_) { return false; }
|
||
}
|
||
function applySpectator() {
|
||
if (!isSpectator()) return;
|
||
var s = document.createElement('style');
|
||
s.textContent = 'html,body{pointer-events:none!important}'
|
||
+ '#ggs-spectator-banner{pointer-events:auto!important;position:fixed;top:0;left:0;right:0;background:#4a7c8a;color:#fff;padding:.5rem;text-align:center;font-family:Inter,sans-serif;font-size:.78rem;font-weight:700;z-index:99998}'
|
||
+ '#ggs-spectator-banner button{pointer-events:auto;margin-left:.6rem;padding:.2rem .6rem;border:none;border-radius:5px;background:#fff;color:#4a7c8a;font-weight:700;cursor:pointer}';
|
||
document.head.appendChild(s);
|
||
var bar = document.createElement('div');
|
||
bar.id = 'ggs-spectator-banner';
|
||
bar.innerHTML = '👁 Live-Ansicht (Lehrkraft) — Eingaben sind deaktiviert. <button onclick="window.close()">Fenster schließen</button>';
|
||
document.body.appendChild(bar);
|
||
}
|
||
|
||
// --- Lifecycle ---
|
||
function start() {
|
||
if (isSpectator()) { applySpectator(); return; } // Spectator nicht heartbeaten
|
||
sendHeartbeat();
|
||
heartbeatTimer = setInterval(sendHeartbeat, 4000);
|
||
checkPause();
|
||
pauseTimer = setInterval(checkPause, 5000);
|
||
// Bei Tab-Schließung sauberer „end"-Heartbeat (keepalive für unload)
|
||
window.addEventListener('beforeunload', function () {
|
||
try {
|
||
navigator.sendBeacon && navigator.sendBeacon(
|
||
apiBase + '/live.php',
|
||
new Blob([JSON.stringify({ action: 'end' })], { type: 'application/json' })
|
||
);
|
||
} catch (_) {}
|
||
});
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', start);
|
||
} else {
|
||
start();
|
||
}
|
||
})();
|