Files
geograsim/App/assets/js/live-client.js
T
Adminator 0d765961af Idle-Pause fuer Heli funktionsfaehig + iframe-aware Interaktionserfassung
- 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>
2026-08-23 12:50:30 +02:00

294 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
}
})();