From 55a83d5daf0b344d7856d023b4f5aae75b90ad21 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 11 Jul 2026 18:07:03 +0200 Subject: [PATCH] Tourismustal: iPad-Feedback Runde 2 (5 Punkte) - Bau-Ablehnung erklaert jetzt immer auch die Bauregeln des Gebaeudes (Toast: Grund + Beschreibung, 7 s). - Lehrer-Schalter fuer den Vorlese-Modus: class_modules.audio_info (Migration, Default AN) + modules-API set_audio_info + Toggle auf der Modul-Karte im Cockpit (Vorlesen an/aus) + Wrapper injiziert TT_FEATURES.audioInfo + Sim blendet den Umschalter aus und erzwingt Lese-Modus, wenn gesperrt. - Bus zeigt bei Rueckfahrt sein Heck: Heckscheibe + rote Ruecklichter, Tuer/Spiegel richtungsabhaengig. - Bikepark-Flowtrails: 3 Stufen hangAUFwaerts (gleiche Richtung wie Lift-Pisten). - Info-Button an jedem Bauwerk im Baumenue: erklaert Wirkung, Kosten, Lage-Regeln und Lehrplan-Wissen VOR dem Bau. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-11-class-modules-audio-info.sql | 7 ++ App/pages/tourismustal.php | 8 ++- App/php/api/modules.php | 18 +++++- App/sims/tourismustal/css/style.css | 8 +++ App/sims/tourismustal/js/data.js | 2 +- App/sims/tourismustal/js/iso.js | 64 ++++++++++++------- App/sims/tourismustal/js/main.js | 3 +- App/sims/tourismustal/js/model.js | 7 +- App/sims/tourismustal/js/ui.js | 37 ++++++++++- App/teacher.html | 12 ++++ 10 files changed, 131 insertions(+), 35 deletions(-) create mode 100644 App/Don_t_Deploy/2026-07-11-class-modules-audio-info.sql diff --git a/App/Don_t_Deploy/2026-07-11-class-modules-audio-info.sql b/App/Don_t_Deploy/2026-07-11-class-modules-audio-info.sql new file mode 100644 index 0000000..f28b0d8 --- /dev/null +++ b/App/Don_t_Deploy/2026-07-11-class-modules-audio-info.sql @@ -0,0 +1,7 @@ +-- ============================================================ +-- class_modules.audio_info: Lehrer-Schalter für den Vorlese-Modus +-- (Audio-Info-Modus in Sims, derzeit Tourismustal). Default AN. +-- Stand: 2026-07-11 · Atlas · idempotent (MariaDB IF NOT EXISTS) +-- ============================================================ +ALTER TABLE class_modules + ADD COLUMN IF NOT EXISTS audio_info TINYINT(1) NOT NULL DEFAULT 1; diff --git a/App/pages/tourismustal.php b/App/pages/tourismustal.php index 5281c4d..713baa7 100644 --- a/App/pages/tourismustal.php +++ b/App/pages/tourismustal.php @@ -19,6 +19,7 @@ $db = getDB(); $sessionId = $_COOKIE['ggs_session'] ?? null; $mode = 'free'; $easy = false; +$audioInfo = true; // Vorlese-Modus: Lehrer kann ihn pro Klasse sperren if ($sessionId) { $stmt = $db->prepare('SELECT class_id, display_name FROM student_sessions WHERE id = ?'); @@ -26,10 +27,13 @@ if ($sessionId) { $sess = $stmt->fetch(PDO::FETCH_ASSOC); if ($sess && !empty($sess['class_id'])) { $classId = (int)$sess['class_id']; - $cm = $db->prepare('SELECT mode FROM class_modules WHERE class_id = ? AND module_id = ?'); + $cm = $db->prepare('SELECT mode, audio_info FROM class_modules WHERE class_id = ? AND module_id = ?'); $cm->execute([$classId, 'tourismustal']); $cmRow = $cm->fetch(PDO::FETCH_ASSOC); $mode = $cmRow ? $cmRow['mode'] : 'locked'; + if ($cmRow !== false && array_key_exists('audio_info', (array)$cmRow)) { + $audioInfo = !empty($cmRow['audio_info']); + } $so = $db->prepare( 'SELECT sm.mode FROM student_modules sm JOIN students s ON s.id = sm.student_id @@ -80,10 +84,12 @@ $modeJs = json_encode($mode); $easyJs = $easy ? 'true' : 'false'; $sessionIdJs = json_encode($sessionId); +$audioInfoJs = $audioInfo ? 'true' : 'false'; $injection = "window.TT_SESSION_MODE = {$modeJs};\n" . "window.TT_SESSION_ID = {$sessionIdJs};\n" . "window.STUDENT_EASY = {$easyJs};\n" . + "window.TT_FEATURES = { audioInfo: {$audioInfoJs} };\n" . "window.TT_API_BASE = '" . BASE_PATH . "/php/api';"; if (strpos($html, '/*__TOURISMUSTAL_INJECTION__*/') !== false) { diff --git a/App/php/api/modules.php b/App/php/api/modules.php index 4a2b18d..fda8520 100644 --- a/App/php/api/modules.php +++ b/App/php/api/modules.php @@ -52,7 +52,7 @@ if ($method === 'GET') { $classId = $session['class_id']; $classMap = []; $settings = $db->fetchAll( - 'SELECT module_id, mode, current_level, quiz_enabled, due_date, started_at, paused + 'SELECT module_id, mode, current_level, quiz_enabled, audio_info, due_date, started_at, paused FROM class_modules WHERE class_id = ?', [$classId]); foreach ($settings as $s) $classMap[$s['module_id']] = $s; @@ -108,6 +108,7 @@ if ($method === 'GET') { 'mode' => $mode, 'forcedLevel' => ($mode === 'teacher_started' && $cm) ? (int)($cm['current_level'] ?? 1) : null, 'quizEnabled' => (bool)($cm['quiz_enabled'] ?? 0), + 'audioInfo' => (bool)($cm['audio_info'] ?? 1), 'dueDate' => $cm['due_date'] ?? null, 'paused' => (bool)($cm['paused'] ?? 0), 'assignmentCompleted' => $assignmentCompleted, @@ -129,7 +130,7 @@ if ($method === 'GET') { // Klassen-Defaults laden (inkl. Level + Pause-Status für die Lehrer-UI) $settings = $db->fetchAll( - 'SELECT module_id, mode, current_level, paused, due_date, started_at FROM class_modules WHERE class_id = ?', + 'SELECT module_id, mode, current_level, paused, audio_info, due_date, started_at FROM class_modules WHERE class_id = ?', [$classId] ); $classMap = []; @@ -191,6 +192,7 @@ if ($method === 'GET') { 'classMode' => $classMap[$mod['id']] ?? 'locked', 'currentLevel' => $meta ? (int)($meta['current_level'] ?? 1) : 1, 'paused' => $meta ? (bool)($meta['paused'] ?? 0) : false, + 'audioInfo' => $meta ? (bool)($meta['audio_info'] ?? 1) : true, 'dueDate' => $meta['due_date'] ?? null, 'startedAt' => $meta['started_at'] ?? null, ]; @@ -264,6 +266,18 @@ if ($method === 'POST') { Response::ok(); } + // Vorlese-Modus (Audio-Info) für die Klasse erlauben/sperren + if ($action === 'set_audio_info') { + $on = !empty($body['enabled']) ? 1 : 0; + $db->execute( + 'INSERT INTO class_modules (class_id, module_id, enabled, audio_info) + VALUES (?, ?, 1, ?) + ON DUPLICATE KEY UPDATE audio_info = VALUES(audio_info)', + [$classId, $moduleId, $on] + ); + Response::ok(); + } + // Auftrag pausieren / fortsetzen if ($action === 'pause' || $action === 'resume') { $paused = $action === 'pause' ? 1 : 0; diff --git a/App/sims/tourismustal/css/style.css b/App/sims/tourismustal/css/style.css index 1564d7c..f7480b2 100644 --- a/App/sims/tourismustal/css/style.css +++ b/App/sims/tourismustal/css/style.css @@ -459,3 +459,11 @@ button.secondary { #infoBanner .ib-icon { font-size: 20px; } #infoBanner .ib-hint { color: var(--muted); font-size: 12px; margin-left: 4px; white-space: nowrap; } @keyframes ibSlide { from { transform: translate(-50%, -14px); opacity: 0; } to { transform: translate(-50%, 0); opacity: 1; } } + +/* ℹ-Button im Baumenü */ +.build-item { position: relative; } +.build-item .bi-menu-info { + background: none; border: none; cursor: pointer; font-size: 14px; + padding: 4px 6px; opacity: .65; flex-shrink: 0; +} +.build-item .bi-menu-info:hover { opacity: 1; } diff --git a/App/sims/tourismustal/js/data.js b/App/sims/tourismustal/js/data.js index 2e43769..4fb2d81 100644 --- a/App/sims/tourismustal/js/data.js +++ b/App/sims/tourismustal/js/data.js @@ -60,7 +60,7 @@ export const BUILDINGS = { }, bikepark: { name: 'Bikepark', emoji: '🚵', cost: 10000, upkeep: 80, - zone: 'mid', staff: 2, summerCap: 160, trailLen: 5, size: 2, + zone: 'mid', staff: 2, summerCap: 160, trailLen: 3, size: 2, desc: 'Großes Basis-Areal (2×2 Felder) mit Flowtrails: bis 160 Sommergäste. Braucht eine Bahn in der Nähe (max. 4 Felder), die die Bikes hinaufbringt.', }, ropes: { diff --git a/App/sims/tourismustal/js/iso.js b/App/sims/tourismustal/js/iso.js index fbe3b65..5e366d9 100644 --- a/App/sims/tourismustal/js/iso.js +++ b/App/sims/tourismustal/js/iso.js @@ -1392,9 +1392,11 @@ export class IsoRenderer { const s = this.state; if (!s.buildings.some(b => b.type === 'access')) return; const ctx = this.ctx; - let f = (this.simTime / 26000) % 1; + const phase = (this.simTime / 26000) % 1; + let f = phase; if (f > 0.5) f = 1 - f; f *= 2; + const dir = phase <= 0.5 ? 1 : -1; // 1 = fährt nach rechts unten, −1 = zurück const c = CORE.c0 + 0.5 + f * (CORE.c1 - CORE.c0 - 1); const r = ROAD_ROW; const tile = s.map[r][Math.min(MAP_COLS - 1, Math.round(c))]; @@ -1432,26 +1434,39 @@ export class IsoRenderer { ctx.lineTo(...P(b, W, winT)); ctx.lineTo(...P(a, W, winT)); ctx.closePath(); ctx.fill(); } - // Türlinie + // Türlinie (Tür sitzt Richtung Front) ctx.strokeStyle = this._shade(body, -34); ctx.lineWidth = 0.8; - ctx.beginPath(); ctx.moveTo(...P(L * 0.06, W)); ctx.lineTo(...P(L * 0.06, W, winB)); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(...P(L * 0.06 * dir, W)); ctx.lineTo(...P(L * 0.06 * dir, W, winB)); ctx.stroke(); - // Front (Fahrtrichtung, +L) + // Sichtbares Ende (+L): je nach Fahrtrichtung FRONT oder HECK ctx.fillStyle = body; ctx.beginPath(); ctx.moveTo(...P(L, W)); ctx.lineTo(...P(L, -W)); ctx.lineTo(...P(L, -W, H)); ctx.lineTo(...P(L, W, H)); ctx.closePath(); ctx.fill(); - // Windschutzscheibe - ctx.fillStyle = 'rgba(223,232,236,0.92)'; - ctx.beginPath(); - ctx.moveTo(...P(L, W * 0.7, winB)); ctx.lineTo(...P(L, -W * 0.7, winB)); - ctx.lineTo(...P(L, -W * 0.7, winT)); ctx.lineTo(...P(L, W * 0.7, winT)); - ctx.closePath(); ctx.fill(); - // Scheinwerfer - ctx.fillStyle = '#ffe6a0'; - const hl1 = P(L, W * 0.6, H * 0.3), hl2 = P(L, -W * 0.6, H * 0.3); - ctx.beginPath(); ctx.arc(hl1[0], hl1[1], 1.1, 0, 7); ctx.arc(hl2[0], hl2[1], 1.1, 0, 7); ctx.fill(); + if (dir === 1) { + // Windschutzscheibe + ctx.fillStyle = 'rgba(223,232,236,0.92)'; + ctx.beginPath(); + ctx.moveTo(...P(L, W * 0.7, winB)); ctx.lineTo(...P(L, -W * 0.7, winB)); + ctx.lineTo(...P(L, -W * 0.7, winT)); ctx.lineTo(...P(L, W * 0.7, winT)); + ctx.closePath(); ctx.fill(); + // Scheinwerfer + ctx.fillStyle = '#ffe6a0'; + const hl1 = P(L, W * 0.6, H * 0.3), hl2 = P(L, -W * 0.6, H * 0.3); + ctx.beginPath(); ctx.arc(hl1[0], hl1[1], 1.1, 0, 7); ctx.arc(hl2[0], hl2[1], 1.1, 0, 7); ctx.fill(); + } else { + // Heckscheibe (kleiner, höher gesetzt) + ctx.fillStyle = 'rgba(223,232,236,0.85)'; + ctx.beginPath(); + ctx.moveTo(...P(L, W * 0.55, winB + 1.5)); ctx.lineTo(...P(L, -W * 0.55, winB + 1.5)); + ctx.lineTo(...P(L, -W * 0.55, winT)); ctx.lineTo(...P(L, W * 0.55, winT)); + ctx.closePath(); ctx.fill(); + // rote Rücklichter + ctx.fillStyle = '#c0392b'; + const tl1 = P(L, W * 0.68, H * 0.3), tl2 = P(L, -W * 0.68, H * 0.3); + ctx.beginPath(); ctx.arc(tl1[0], tl1[1], 1.2, 0, 7); ctx.arc(tl2[0], tl2[1], 1.2, 0, 7); ctx.fill(); + } // Stoßstange ctx.strokeStyle = '#2f3e46'; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.moveTo(...P(L, W, H * 0.16)); ctx.lineTo(...P(L, -W, H * 0.16)); ctx.stroke(); @@ -1478,18 +1493,19 @@ export class IsoRenderer { ctx.quadraticCurveTo(wp[0], wp[1] - 4.2, wp[0] + 3.7, wp[1] + 1); ctx.stroke(); } - // Türgriff + // Türgriff (Richtung Front) ctx.strokeStyle = this._shade(body, -42); ctx.lineWidth = 1; - const gh = P(L * 0.16, W, winB * 0.7); + const gh = P(L * 0.16 * dir, W, winB * 0.7); ctx.beginPath(); ctx.moveTo(gh[0] - 2, gh[1]); ctx.lineTo(gh[0] + 2, gh[1]); ctx.stroke(); - // Außenspiegel vorne - ctx.fillStyle = '#2f3e46'; - const mir = P(L, W * 1.4, H * 0.62); - ctx.beginPath(); ctx.arc(mir[0], mir[1], 1.3, 0, 7); ctx.fill(); - // Rücklicht hinten - ctx.fillStyle = '#c0392b'; - const rl = P(-L, W, H * 0.3); - ctx.beginPath(); ctx.arc(rl[0], rl[1], 1.2, 0, 7); ctx.fill(); + if (dir === 1) { + // Außenspiegel vorne + Rücklicht am hinteren (verdeckten) Ende + ctx.fillStyle = '#2f3e46'; + const mir = P(L, W * 1.4, H * 0.62); + ctx.beginPath(); ctx.arc(mir[0], mir[1], 1.3, 0, 7); ctx.fill(); + ctx.fillStyle = '#c0392b'; + const rl = P(-L, W, H * 0.3); + ctx.beginPath(); ctx.arc(rl[0], rl[1], 1.2, 0, 7); ctx.fill(); + } ctx.lineWidth = 1; } diff --git a/App/sims/tourismustal/js/main.js b/App/sims/tourismustal/js/main.js index 8384d7e..e80da5a 100644 --- a/App/sims/tourismustal/js/main.js +++ b/App/sims/tourismustal/js/main.js @@ -123,7 +123,8 @@ function onTileTap(c, r) { const check = canPlace(state, type, c, r); if (!check.ok) { - ui.toast(`❌ ${check.reason}`); + // Ablehnung erklärt IMMER auch die Bauregeln des Gebäudes + ui.toast(`❌ ${check.reason} 💡 ${BUILDINGS[type].name}: ${BUILDINGS[type].desc}`, 7000); audio.error(); telemetry.log('build_rejected', { type, c, r, reason: check.reason }); return; diff --git a/App/sims/tourismustal/js/model.js b/App/sims/tourismustal/js/model.js index ce2cd1e..a261aeb 100644 --- a/App/sims/tourismustal/js/model.js +++ b/App/sims/tourismustal/js/model.js @@ -347,12 +347,13 @@ export function place(state, type, c, r) { } } if (def.trailLen) { - // Bike-Trails schlängeln sich talwärts unter der Basisstation (Bikepark). - // Sichtbar groß, aber nur 1 Feld Gebäude – und sie kosten extra Natur. + // Bike-Trails ziehen sich hangAUFwärts über der Basisstation – gleiche + // Richtung wie die Lift-Pisten (die Bahn bringt die Bikes hinauf, die + // Trails führen zurück herunter). Sie kosten extra Natur. let cc = c; for (let i = 1; i <= def.trailLen; i++) { cc += (i % 2 ? 1 : -1); - const t = state.map[r + i]?.[Math.max(1, Math.min(MAP_COLS - 2, cc))]; + const t = state.map[r - i]?.[Math.max(1, Math.min(MAP_COLS - 2, cc))]; if (t && t.terrain !== 'water' && !t.building) { state.trails.push({ c: t.c, r: t.r, owner: { c, r } }); } diff --git a/App/sims/tourismustal/js/ui.js b/App/sims/tourismustal/js/ui.js index 92c0c5e..859dc33 100644 --- a/App/sims/tourismustal/js/ui.js +++ b/App/sims/tourismustal/js/ui.js @@ -2,7 +2,7 @@ // Auto-Pause), Meldungs-Log, Graphen, Skipass-Regler, Ziele, Toasts, // Onboarding, Szenenwahl und Topbar-Buttons. -import { BUILDINGS } from './data.js'; +import { BUILDINGS, BUILD_INFO, KNOWLEDGE } from './data.js'; import { seasonOfDay, DAYS_PER_YEAR } from './model.js'; import { telemetry } from './telemetry.js'; import { audio } from './audio.js'; @@ -18,7 +18,11 @@ export class UI { this.cardOpen = false; this.speedBeforeCard = 1; this.collectedCards = []; - this.infoMode = localStorage.getItem('tt_infomode') || 'read'; + // Vorlese-Modus: kann von der Lehrperson pro Klasse gesperrt werden + this.audioAllowed = window.TT_FEATURES?.audioInfo !== false; + this.infoMode = this.audioAllowed + ? (localStorage.getItem('tt_infomode') || 'read') + : 'read'; this.activeBuild = null; this.toastTimer = null; @@ -77,11 +81,37 @@ export class UI { el.innerHTML = ` ${emoji} ${name}${desc} - ${cost}`; + ${cost} + `; el.addEventListener('click', () => this._toggleBuild(type, el)); + el.querySelector('.bi-menu-info').addEventListener('click', e => { + e.stopPropagation(); // nicht in den Baumodus wechseln + audio.click(); + this.showBuildInfo(type); + }); return el; } + // ℹ im Baumenü: erklärt ein Bauwerk VOR dem Bau – Wirkung, Kosten, + // Lage-Regeln und das Lehrplan-Wissen dazu. + showBuildInfo(type) { + const def = BUILDINGS[type]; + const info = BUILD_INFO[type]; + const know = (KNOWLEDGE[type] || []) + .map(k => `📚 ${k.term}: ${k.text}`).join('\n\n'); + const staffLine = def.staff ? ` + ${def.staff} Angestellte` : ''; + this.showCard({ + id: `menuinfo-${type}`, transient: true, forceModal: true, + icon: def.emoji, sound: 'none', + title: def.name, + text: `${def.desc}\n\nBaukosten: ${def.cost.toLocaleString('de-AT')} € · ` + + `laufend: ${def.upkeep} €/Tag${staffLine}.` + + (info ? `\n\n${info.text}` : '') + + (know ? `\n\n${know}` : ''), + }); + telemetry.log('menu_info', { type }); + } + _toggleBuild(type, el) { audio.click(); document.querySelectorAll('.build-item').forEach(i => i.classList.remove('active')); @@ -141,6 +171,7 @@ export class UI { $('playerPanel').classList.toggle('hidden'); telemetry.log('player_panel', { open: !$('playerPanel').classList.contains('hidden') }); }); + if (!this.audioAllowed) $('btnInfoMode').classList.add('hidden'); $('btnInfoMode').addEventListener('click', e => { audio.click(); this.infoMode = this.infoMode === 'audio' ? 'read' : 'audio'; diff --git a/App/teacher.html b/App/teacher.html index 6c589cd..feacacd 100644 --- a/App/teacher.html +++ b/App/teacher.html @@ -2311,6 +2311,11 @@ async function loadModuleMatrix() { if (!coming) { s += ''; } + // Vorlese-Modus (Audio-Info) pro Klasse erlauben/sperren — Sims mit Sprachausgabe + if (!coming && AUDIO_INFO_MODULES[m.id]) { + var aOn = m.audioInfo !== false; + s += ''; + } s += '📖 Infos'; s += '📚 Glossar'; s += '📋 Lehrplan'; @@ -2386,6 +2391,13 @@ async function toggleAssignmentPause(moduleId, paused) { loadModuleMatrix(); } +// Sims mit Vorlese-Modus (gesprochene Info-Meldungen) — Lehrer-Schalter pro Klasse +var AUDIO_INFO_MODULES = { tourismustal: true }; +async function toggleAudioInfo(moduleId, current) { + await api('modules', {action:'set_audio_info', classId:currentClassId, moduleId:moduleId, enabled: !current}); + loadModuleMatrix(); +} + // Klick auf Modul-Spalte in Matrix: toggelt alle Schueler dieser Klasse fuer // das Modul. Setzt Klassen-Default auf locked oder free und loescht alle // individuellen Overrides — Schueler erben den Default.