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.