/** * 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 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; function onInteraction() { lastInteractionAt = Date.now(); } // Passive Listener, damit Scrollen/Touch nicht ausgebremst wird. var EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel', 'click', 'pointerdown']; EVENTS.forEach(function (ev) { window.addEventListener(ev, onInteraction, { passive: true, capture: true }); }); // 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; // 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; 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; }, 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; 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 = '