/** * 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 * * 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 = '
' + '
Auftrag pausiert
' + '
Deine Lehrperson hat das Tempo angehalten. Sobald sie freigibt, kannst du weitermachen.
'; 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 = '
😴
' + '
Kurze Pause
' + '
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.
' + ''; 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. '; 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(); } })();