User Management + Didaktisches Handbuch + Stadt-Editor + Emoji-Kacheln

User Management (Backend):
- schema-v2.sql: students, licenses, student_results, activity_log Tabellen
- auth.php: Lehrer-Registrierung/Login, Schueler-Login, Status-Check
- classes.php: CRUD fuer Klassenverwaltung (mit Join-Code-Generator)
- students.php: Schueler erstellen (einzeln + Batch), bearbeiten, loeschen
- modules.php: Modulfreigabe pro Klasse (locked/free/teacher_started)

Didaktisches Handbuch:
- handbuch.html: Vollstaendiger Lehrpersonen-Guide
- 6 Module detailliert beschrieben (Lernziele, Kompetenzen)
- Vor/Nach-Fragen fuer jede Simulation
- Empfohlener Unterrichtsablauf (45-min-Einheit)
- Differenzierungshinweise, Bewertungsempfehlungen

Stadt-Editor:
- stadt-editor.html: Funktionierendes 8x8 Raster mit Kachel-Platzierung
- 15 Emoji-Gebaude-Kacheln (Kirche, Moschee, Wohngebiet, Fabrik, etc.)
- Spielplatz: Rutsche+Karussell statt schwebende Koepfe
- Hochhaeuser: 6 Gebaeude in 2 Reihen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 01:52:55 +02:00
parent 32ed869f23
commit 33fae37838
8 changed files with 1582 additions and 43 deletions
+323
View File
@@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Didaktisches Handbuch für Lehrpersonen</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter',system-ui,sans-serif; background:#f7f6f3; color:#1a1a1a; line-height:1.7; }
.wrap { max-width:820px; margin:0 auto; padding:2rem 1.5rem; }
h1 { font-size:2rem; font-weight:800; letter-spacing:-.03em; margin-bottom:.5rem; color:#4a7c8a; }
h2 { font-size:1.3rem; font-weight:800; color:#4a7c8a; margin:2.5rem 0 .8rem; padding-bottom:.3rem; border-bottom:2px solid #dae8ec; }
h3 { font-size:1rem; font-weight:700; color:#3a6470; margin:1.5rem 0 .5rem; }
p { margin-bottom:.8rem; font-size:.92rem; }
ul, ol { margin:0 0 1rem 1.5rem; font-size:.9rem; }
li { margin-bottom:.3rem; }
.lead { color:#5a5a5a; font-size:1rem; margin-bottom:1.5rem; }
.module { background:#fff; border-radius:12px; padding:1.2rem; margin-bottom:1rem; border:1px solid rgba(0,0,0,.05); box-shadow:0 2px 8px rgba(0,0,0,.03); }
.module h3 { margin-top:0; }
.tag { display:inline-block; font-size:.68rem; font-weight:600; padding:2px 8px; border-radius:5px; margin-right:.3rem; }
.tag-time { background:#f0eeea; color:#8a8a8a; }
.tag-level { background:#dae8ec; color:#4a7c8a; }
.tag-topic { background:#dceadd; color:#5a8a5e; }
table { width:100%; border-collapse:collapse; margin:1rem 0; font-size:.85rem; }
th { background:#f0eeea; padding:.5rem .7rem; text-align:left; font-weight:700; font-size:.78rem; color:#4a4a4a; }
td { padding:.5rem .7rem; border-bottom:1px solid rgba(0,0,0,.04); }
.qa { background:#faf8f4; border-left:3px solid #c4a35a; padding:.7rem 1rem; margin:.8rem 0; border-radius:0 8px 8px 0; }
.qa strong { color:#8a6a2a; font-size:.82rem; }
.qa p { margin-bottom:.3rem; font-size:.85rem; }
footer { margin-top:3rem; padding-top:1rem; border-top:1px solid rgba(0,0,0,.06); color:#8a8a8a; font-size:.78rem; text-align:center; }
@media print { body { background:#fff; } .wrap { max-width:100%; } }
</style>
</head>
<body>
<div class="wrap">
<h1>Didaktisches Handbuch</h1>
<p class="lead">GeoGraSim — Interaktive Simulationen für den Geografieunterricht der Sekundarstufe I.<br>
Dieses Handbuch richtet sich an Lehrpersonen und beschreibt Zweck, Einsatz und didaktische Einbettung jeder Simulation.</p>
<h2>1. Überblick</h2>
<p>GeoGraSim ist eine Sammlung interaktiver Simulationen, die zentrale Themen des Geografieunterrichts erfahrbar machen. Die Schülerinnen und Schüler treffen Entscheidungen in komplexen Systemen und erleben die Konsequenzen — Zielkonflikte, Rückkopplungen und nichtlineare Zusammenhänge werden spürbar statt nur beschrieben.</p>
<h3>Didaktische Grundidee</h3>
<ul>
<li><strong>Systemdenken fördern:</strong> Schüler:innen erkennen, dass Entscheidungen in Mensch-Umwelt-Systemen vielfältige Auswirkungen haben.</li>
<li><strong>Zielkonflikte erfahren:</strong> Es gibt keine „perfekte" Lösung — jede Maßnahme hat Vor- und Nachteile.</li>
<li><strong>Handlungskompetenz aufbauen:</strong> Durch Experimentieren und Reflektieren entwickeln Lernende informierte Positionen.</li>
<li><strong>Lehrplankonform:</strong> Alle Module sind auf den österreichischen Lehrplan (GW, Sek I) abgestimmt, mit Bezügen zu DACH-Curricula.</li>
</ul>
<h3>Einsatzformen</h3>
<ul>
<li><strong>Geführter Unterricht:</strong> Lehrperson startet ein Modul für die ganze Klasse, begleitet und reflektiert gemeinsam.</li>
<li><strong>Freie Übung:</strong> Schüler:innen arbeiten selbstständig an freigegebenen Modulen.</li>
<li><strong>Hausübung:</strong> Module mit Abgabefrist als vertiefende Aufgabe.</li>
<li><strong>Vergleich und Diskussion:</strong> Ergebnisse verschiedener Schüler:innen vergleichen und Strategien besprechen.</li>
</ul>
<h2>2. Module im Detail</h2>
<!-- ===== KLIMAWÄCHTER ===== -->
<div class="module">
<h3>🌡️ Klimawächter (SIM-05)</h3>
<span class="tag tag-level">1. Klasse</span>
<span class="tag tag-time">⏱ 20 min</span>
<span class="tag tag-topic">Klimawandel</span>
<p><strong>Beschreibung:</strong> Die Schüler:innen übernehmen die Rolle der Klimaminister:in eines Inselstaats und steuern diesen 75 Jahre lang (20252100) durch die Klimakrise. Sie müssen CO₂-Emissionen reduzieren, die Wirtschaft am Laufen halten und die Bevölkerung vor dem steigenden Meeresspiegel schützen.</p>
<p><strong>Lernziele:</strong></p>
<ul>
<li>Zusammenhang zwischen CO₂, Temperatur und Meeresspiegel verstehen</li>
<li>Verschiedene Klimaschutzmaßnahmen und ihre Trade-offs kennenlernen</li>
<li>Unterschied zwischen kurzfristigen und langfristigen Folgen erfahren</li>
<li>Erkennen, dass Nichtstun schwerwiegende Konsequenzen hat</li>
</ul>
<p><strong>Kompetenzbereiche:</strong> Mensch-Umwelt-Beziehungen, Nachhaltigkeit, Raumverantwortung</p>
<div class="qa">
<strong>Fragen VOR der Simulation:</strong>
<p>• Was ist der Treibhauseffekt? Warum ist CO₂ ein Problem?</p>
<p>• Welche Maßnahmen gegen den Klimawandel kennst du?</p>
<p>• Was passiert, wenn der Meeresspiegel steigt?</p>
</div>
<div class="qa">
<strong>Fragen NACH der Simulation:</strong>
<p>• Welche Maßnahmen hast du gewählt? Warum?</p>
<p>• Gab es Situationen, in denen du dich zwischen Wirtschaft und Umwelt entscheiden musstest?</p>
<p>• Was wäre passiert, wenn du nichts gemacht hättest?</p>
<p>• Welche Maßnahme war am wirksamsten? Welche am teuersten?</p>
<p>• Wie realistisch findest du die Simulation?</p>
</div>
</div>
<!-- ===== ERDBEBEN ===== -->
<div class="module">
<h3>🌋 Erdbeben-Simulator (SIM-07)</h3>
<span class="tag tag-level">1. Klasse</span>
<span class="tag tag-time">⏱ 25 min</span>
<span class="tag tag-topic">Naturgefahr</span>
<p><strong>Beschreibung:</strong> 50 Jahre lang eine Stadt in einer Erdbebenregion aufbauen. Die Bauqualität entscheidet darüber, ob ein Beben eine Naturgefahr oder eine Naturkatastrophe wird.</p>
<p><strong>Lernziele:</strong></p>
<ul>
<li>Unterschied zwischen Naturgefahr und Naturkatastrophe verstehen</li>
<li>Zusammenhang zwischen Bauqualität und Verwundbarkeit erkennen</li>
<li>Kosten-Nutzen-Abwägung bei Schutzmaßnahmen</li>
</ul>
<div class="qa">
<strong>Fragen VOR der Simulation:</strong>
<p>• Warum sind Erdbeben in manchen Ländern katastrophaler als in anderen?</p>
<p>• Was bedeutet „Vulnerabilität"?</p>
</div>
<div class="qa">
<strong>Fragen NACH der Simulation:</strong>
<p>• Wie hast du deine Stadt gebaut? Was hat bei Erdbeben standgehalten?</p>
<p>• Was wäre passiert, wenn du nur billig gebaut hättest?</p>
<p>• Vergleiche: Japan und Haiti bei ähnlich starken Beben — warum sind die Folgen so unterschiedlich?</p>
</div>
</div>
<!-- ===== ENERGIEMIX ===== -->
<div class="module">
<h3>⚡ Energiemix-Planer (SIM-09)</h3>
<span class="tag tag-level">2. Klasse</span>
<span class="tag tag-time">⏱ 25 min</span>
<span class="tag tag-topic">Energie</span>
<p><strong>Beschreibung:</strong> Die Region bis 2050 auf 80% erneuerbare Energie umstellen — ohne Blackout und ohne Pleite. Der Energiemix muss Kosten, CO₂, Versorgungssicherheit und Akzeptanz balancieren.</p>
<p><strong>Lernziele:</strong></p>
<ul>
<li>Verschiedene Energiequellen und ihre Vor-/Nachteile kennen</li>
<li>Konzept der Energiewende verstehen</li>
<li>Trade-offs zwischen Kosten, Umwelt und Versorgungssicherheit</li>
</ul>
<div class="qa">
<strong>Fragen VOR der Simulation:</strong>
<p>• Woher kommt der Strom, den du zu Hause nutzt?</p>
<p>• Welche Energiequellen sind erneuerbar? Welche nicht?</p>
</div>
<div class="qa">
<strong>Fragen NACH der Simulation:</strong>
<p>• Welchen Energiemix hast du gewählt? Warum?</p>
<p>• Was passiert, wenn die Sonne nicht scheint und kein Wind weht?</p>
<p>• Warum kann man nicht einfach alles auf Solar umstellen?</p>
</div>
</div>
<!-- ===== FLUSSMANAGEMENT ===== -->
<div class="module">
<h3>🏞️ Flussmanagement (SIM-12)</h3>
<span class="tag tag-level">1.2. Klasse</span>
<span class="tag tag-time">⏱ 2035 min</span>
<span class="tag tag-topic">Hydrologie</span>
<p><strong>Beschreibung:</strong> Ein Flusssystem über 100 Jahre verwalten. Der Fluss ändert seinen Lauf, das Wetter ist unberechenbar. Siedlungen, Felder und Deiche müssen klug platziert werden.</p>
<p><strong>Lernziele:</strong></p>
<ul>
<li>Flussdynamik und natürliche Überschwemmungsprozesse verstehen</li>
<li>Historische Eingriffe in Gewässer (Begradigung, Deiche) und ihre Folgen kennen</li>
<li>Zielkonflikte zwischen Hochwasserschutz, Landwirtschaft, Ökologie und Wirtschaft</li>
<li>Konzept der Renaturierung und „dem Fluss Raum geben"</li>
</ul>
<div class="qa">
<strong>Fragen VOR der Simulation:</strong>
<p>• Warum gibt es Hochwasser? Ist Hochwasser immer schlecht?</p>
<p>• Was ist ein Mäander? Warum begradigt man Flüsse?</p>
<p>• Kennst du einen Fluss in deiner Nähe? Ist er begradigt?</p>
</div>
<div class="qa">
<strong>Fragen NACH der Simulation:</strong>
<p>• Was war deine Strategie? Hat sie funktioniert?</p>
<p>• Was passierte, als der Fluss seinen Lauf änderte?</p>
<p>• Was war effektiver: Deiche bauen oder dem Fluss Raum lassen?</p>
<p>• Warum ist Begradigung kurzfristig hilfreich, aber langfristig problematisch?</p>
<p>• Übertrage: Wie sollte man mit Flüssen in der Realität umgehen?</p>
</div>
</div>
<!-- ===== STADT & RAUMPLANUNG ===== -->
<div class="module">
<h3>🏘️ Stadt & Raumplanung (SIM-13)</h3>
<span class="tag tag-level">3. Klasse</span>
<span class="tag tag-time">⏱ 2040 min</span>
<span class="tag tag-topic">Raumplanung</span>
<p><strong>Beschreibung:</strong> Eine Siedlung schrittweise aufbauen durch Platzierung von Kacheln (Straßen, Häuser, Geschäfte, Parks, Industrie). Jede Platzierung hat Folgen für Wirtschaft, Zufriedenheit, Umwelt und Verkehr.</p>
<p><strong>Lernziele:</strong></p>
<ul>
<li>Grundlagen der Raumplanung und Flächennutzung verstehen</li>
<li>Infrastruktur-Abhängigkeiten erkennen (Straßen, Versorgung, Erreichbarkeit)</li>
<li>Zielkonflikte zwischen Wachstum, Lebensqualität und Nachhaltigkeit</li>
<li>Bedeutung von Nahversorgung, Grünräumen und Verkehrsplanung</li>
</ul>
<div class="qa">
<strong>Fragen VOR der Simulation:</strong>
<p>• Was braucht eine Siedlung, damit die Menschen dort gut leben können?</p>
<p>• Warum stehen Fabriken meistens am Stadtrand und nicht in der Mitte?</p>
<p>• Was ist „Nahversorgung"? Warum ist sie wichtig?</p>
</div>
<div class="qa">
<strong>Fragen NACH der Simulation:</strong>
<p>• Wie hast du deine Stadt geplant? Was hast du zuerst gebaut?</p>
<p>• Gab es Probleme, als die Stadt gewachsen ist?</p>
<p>• Was würdest du beim nächsten Mal anders machen?</p>
<p>• Welche Gebäude haben die Zufriedenheit am meisten beeinflusst? Warum?</p>
<p>• Vergleiche deine Stadt mit der eines Mitschülers: Was ist anders? Was ist besser?</p>
</div>
</div>
<!-- ===== LIEFERKETTEN ===== -->
<div class="module">
<h3>👕 Lieferketten-Planer (SIM-10)</h3>
<span class="tag tag-level">2. Klasse</span>
<span class="tag tag-time">⏱ 15 min</span>
<span class="tag tag-topic">Globalisierung</span>
<p><strong>Beschreibung:</strong> Was kostet dein T-Shirt wirklich? Bestelle Produkte aus aller Welt und finde die Balance zwischen Preis, CO₂ und fairen Arbeitsbedingungen.</p>
<p><strong>Lernziele:</strong></p>
<ul>
<li>Globale Lieferketten und Transportwege verstehen</li>
<li>Zusammenhang zwischen Preis, Transportweg und Arbeitsbedingungen</li>
<li>Konzept des „ökologischen Fußabdrucks" bei Konsumgütern</li>
</ul>
<div class="qa">
<strong>Fragen VOR der Simulation:</strong>
<p>• Schau auf dein T-Shirt: Wo wurde es hergestellt? Wie weit ist es gereist?</p>
<p>• Warum sind manche Produkte billig, obwohl sie von weit her kommen?</p>
</div>
<div class="qa">
<strong>Fragen NACH der Simulation:</strong>
<p>• Welche Produkte hast du gewählt? Warum?</p>
<p>• Was hat dich überrascht am CO₂-Verbrauch?</p>
<p>• Kann man immer fair UND günstig UND umweltfreundlich kaufen?</p>
</div>
</div>
<h2>3. Einsatz im Unterricht</h2>
<h3>Empfohlener Ablauf (45-Minuten-Einheit)</h3>
<table>
<tr><th>Phase</th><th>Dauer</th><th>Aktivität</th></tr>
<tr><td>Einstieg</td><td>5 min</td><td>Fragen VOR der Simulation besprechen. Vorwissen aktivieren.</td></tr>
<tr><td>Einführung</td><td>3 min</td><td>Modul kurz vorstellen, Spielprinzip erklären.</td></tr>
<tr><td>Simulation</td><td>2025 min</td><td>Schüler:innen spielen selbstständig (Einzel- oder Partnerarbeit).</td></tr>
<tr><td>Auswertung</td><td>5 min</td><td>Ergebnisse vergleichen (Lehrkraft zeigt Dashboard).</td></tr>
<tr><td>Reflexion</td><td>7 min</td><td>Fragen NACH der Simulation. Was habe ich gelernt? Transfer auf die Realität.</td></tr>
</table>
<h3>Differenzierung</h3>
<ul>
<li><strong>Leistungsschwächere:</strong> Einfachstes Level, mehr Zeit, Partnerarbeit</li>
<li><strong>Leistungsstärkere:</strong> Höheres Level, zusätzliche Reflexionsfragen, eigene Strategien dokumentieren</li>
<li><strong>Sprachförderung:</strong> Module können zweisprachig (DE/EN) eingesetzt werden</li>
</ul>
<h2>4. Bewertung und Leistungserfassung</h2>
<p>Das System erfasst automatisch:</p>
<ul>
<li>Gesamtscore pro Modul (gewichtete Kombination der Teilwerte)</li>
<li>Balance-Index (wie ausgewogen ist die Lösung?)</li>
<li>Dauer der Bearbeitung</li>
<li>Anzahl der getroffenen Entscheidungen</li>
<li>Strategieprofil (einseitig vs. ausgewogen)</li>
</ul>
<p><strong>Wichtig:</strong> Die Simulation ist ein Lernwerkzeug, kein Test. Der Score dient der Reflexion, nicht der Benotung. Empfehlung: Bewerten Sie den Reflexionsbeitrag (mündlich oder schriftlich), nicht den Spielscore.</p>
<h3>Empfohlene Reflexions-Aufgaben (zur Leistungsfeststellung)</h3>
<ol>
<li>„Beschreibe deine Strategie in 35 Sätzen. Was war dein wichtigstes Ziel?"</li>
<li>„Nenne einen Zielkonflikt, den du erlebt hast. Wie hast du dich entschieden?"</li>
<li>„Was würdest du in der Realität anders machen als im Spiel? Warum?"</li>
<li>„Vergleiche dein Ergebnis mit dem einer Mitschülerin. Was war anders? Warum?"</li>
</ol>
<h2>5. Technische Hinweise</h2>
<ul>
<li><strong>Browser:</strong> Chrome, Firefox, Safari, Edge (aktuell). Funktioniert auf Desktop, Laptop, Tablet.</li>
<li><strong>Internet:</strong> Erforderlich für Login und Ergebnis-Speicherung. Simulationen laufen clientseitig.</li>
<li><strong>Accounts:</strong> Lehrperson erstellt Klasse → generiert Schüler-Zugänge → Schüler loggen sich mit Klassencode + Benutzername + Passwort ein.</li>
<li><strong>Datenschutz:</strong> Schüler-Accounts können anonym (Pseudonym) sein. Keine E-Mail-Adresse erforderlich.</li>
</ul>
<h2>6. Forschungsbasis</h2>
<p>GeoGraSim basiert auf umfassender Forschungsarbeit:</p>
<ul>
<li>195 Quellen zur Simulationsforschung (Wirksamkeit, Gestaltung, Qualität)</li>
<li>135 Quellen zur Geografiedidaktik (Fokus DACH-Raum)</li>
<li>Lehrplan-Mapping: 24 Simulationsmodule auf 14 Kompetenzbereiche des österreichischen Lehrplans (BGBl. II Nr. 178/2025)</li>
<li>DACH-Lehrplanvergleich (AT, DE-BY/NRW/BW, CH, UK)</li>
</ul>
<footer>
<p>GeoGraSim — Schroffenegger & Mößlang, 2026</p>
<p>Dieses Handbuch ist Teil des GeoGraSim-Lernprogramms.</p>
</footer>
</div>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
<?php
/**
* API: Authentifizierung (Lehrer + Schueler)
* POST /api/auth {action: "register"|"login"|"logout"|"student_login"|"reset_request"|"reset_confirm"}
*/
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
Response::error('Nur POST erlaubt', 405);
}
$body = json_decode(file_get_contents('php://input'), true);
$action = $body['action'] ?? '';
$db = Database::get();
// === LEHRER-REGISTRIERUNG ===
if ($action === 'register') {
$email = trim($body['email'] ?? '');
$password = $body['password'] ?? '';
$displayName = mb_substr(trim($body['displayName'] ?? ''), 0, 128);
$schoolName = mb_substr(trim($body['schoolName'] ?? ''), 0, 255);
if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) Response::error('Gültige E-Mail-Adresse erforderlich');
if (strlen($password) < 8) Response::error('Passwort muss mindestens 8 Zeichen lang sein');
if (!$displayName) Response::error('Name erforderlich');
// Pruefen ob E-Mail schon existiert
$existing = $db->fetchOne('SELECT id FROM teachers WHERE email = ?', [$email]);
if ($existing) Response::error('Diese E-Mail-Adresse ist bereits registriert');
$username = strtolower(explode('@', $email)[0]) . '_' . rand(100, 999);
$hash = password_hash($password, PASSWORD_DEFAULT);
$db->execute(
'INSERT INTO teachers (username, email, password, display_name, school_name) VALUES (?, ?, ?, ?, ?)',
[$username, $email, $hash, $displayName, $schoolName]
);
$teacherId = (int)$db->lastInsertId();
Session::start();
Session::loginTeacher($teacherId);
Response::ok([
'teacherId' => $teacherId,
'displayName' => $displayName,
]);
}
// === LEHRER-LOGIN ===
if ($action === 'login') {
$email = trim($body['email'] ?? '');
$password = $body['password'] ?? '';
if (!$email || !$password) Response::error('E-Mail und Passwort erforderlich');
$teacher = $db->fetchOne(
'SELECT id, password, display_name FROM teachers WHERE email = ? OR username = ?',
[$email, $email]
);
if (!$teacher || !password_verify($password, $teacher['password'])) {
Response::error('E-Mail oder Passwort falsch');
}
Session::start();
Session::loginTeacher((int)$teacher['id']);
$db->execute(
"INSERT INTO activity_log (user_type, user_id, action) VALUES ('teacher', ?, 'login')",
[$teacher['id']]
);
Response::ok([
'teacherId' => (int)$teacher['id'],
'displayName' => $teacher['display_name'],
]);
}
// === LOGOUT ===
if ($action === 'logout') {
Session::logout();
Response::ok();
}
// === SCHUELER-LOGIN ===
if ($action === 'student_login') {
$classCode = strtoupper(trim($body['classCode'] ?? ''));
$username = trim($body['username'] ?? '');
$password = $body['password'] ?? '';
if (!$classCode || !$username || !$password) Response::error('Klassencode, Benutzername und Passwort erforderlich');
$class = $db->fetchOne('SELECT id FROM classes WHERE join_code = ?', [$classCode]);
if (!$class) Response::error('Klasse nicht gefunden');
$student = $db->fetchOne(
'SELECT id, password, display_name FROM students WHERE class_id = ? AND username = ?',
[$class['id'], $username]
);
if (!$student || !password_verify($password, $student['password'])) {
Response::error('Benutzername oder Passwort falsch');
}
// Schueler-Session erstellen
$sessionId = Session::createStudent((int)$class['id'], $student['display_name'] ?: $username);
$db->execute('UPDATE students SET last_login = NOW() WHERE id = ?', [$student['id']]);
$db->execute(
"INSERT INTO activity_log (user_type, user_id, action) VALUES ('student', ?, 'login')",
[$student['id']]
);
Response::ok([
'sessionId' => $sessionId,
'studentId' => (int)$student['id'],
'displayName' => $student['display_name'] ?: $username,
'className' => $class['name'] ?? '',
]);
}
// === STATUS ===
if ($action === 'status') {
Session::start();
$teacherId = Session::teacherId();
$studentId = Session::studentId();
if ($teacherId) {
$teacher = $db->fetchOne('SELECT id, display_name, email, school_name FROM teachers WHERE id = ?', [$teacherId]);
Response::ok(['role' => 'teacher', 'user' => $teacher]);
}
if ($studentId) {
$session = $db->fetchOne(
'SELECT s.display_name, s.class_id, c.name as class_name FROM student_sessions s LEFT JOIN classes c ON c.id = s.class_id WHERE s.id = ?',
[$studentId]
);
Response::ok(['role' => 'student', 'session' => $session]);
}
Response::ok(['role' => 'guest']);
}
Response::error('Unbekannte Aktion');
+80
View File
@@ -0,0 +1,80 @@
<?php
/**
* API: Klassenverwaltung (nur fuer Lehrer)
* GET /api/classes → Alle Klassen des Lehrers
* POST /api/classes {action} → Klasse erstellen/bearbeiten/loeschen
*/
$method = $_SERVER['REQUEST_METHOD'];
$db = Database::get();
if ($method === 'GET') {
$teacherId = Session::requireTeacher();
$classes = $db->fetchAll(
'SELECT id, name, school_year, join_code, created_at FROM classes WHERE teacher_id = ? ORDER BY created_at DESC',
[$teacherId]
);
// Schueleranzahl pro Klasse
foreach ($classes as &$c) {
$c['student_count'] = (int)$db->fetchOne(
'SELECT COUNT(*) as cnt FROM students WHERE class_id = ?', [$c['id']]
)['cnt'];
}
Response::ok($classes);
}
if ($method === 'POST') {
$teacherId = Session::requireTeacher();
$body = json_decode(file_get_contents('php://input'), true);
$action = $body['action'] ?? '';
// === KLASSE ERSTELLEN ===
if ($action === 'create') {
$name = mb_substr(trim($body['name'] ?? ''), 0, 64);
$schoolYear = mb_substr(trim($body['schoolYear'] ?? ''), 0, 10);
if (!$name) Response::error('Klassenname erforderlich');
// Join-Code generieren (6 Zeichen, eindeutig)
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // ohne I,O,0,1 (Verwechslungsgefahr)
do {
$code = '';
for ($i = 0; $i < 6; $i++) $code .= $chars[random_int(0, strlen($chars) - 1)];
$exists = $db->fetchOne('SELECT id FROM classes WHERE join_code = ?', [$code]);
} while ($exists);
$db->execute(
'INSERT INTO classes (teacher_id, name, school_year, join_code) VALUES (?, ?, ?, ?)',
[$teacherId, $name, $schoolYear, $code]
);
Response::ok(['id' => (int)$db->lastInsertId(), 'joinCode' => $code]);
}
// === KLASSE BEARBEITEN ===
if ($action === 'update') {
$classId = (int)($body['classId'] ?? 0);
$name = mb_substr(trim($body['name'] ?? ''), 0, 64);
$schoolYear = mb_substr(trim($body['schoolYear'] ?? ''), 0, 10);
if (!$classId || !$name) Response::error('classId und name erforderlich');
// Sicherstellen dass Klasse dem Lehrer gehoert
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
if (!$class) Response::error('Klasse nicht gefunden', 404);
$db->execute('UPDATE classes SET name = ?, school_year = ? WHERE id = ?', [$name, $schoolYear, $classId]);
Response::ok();
}
// === KLASSE LOESCHEN ===
if ($action === 'delete') {
$classId = (int)($body['classId'] ?? 0);
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
if (!$class) Response::error('Klasse nicht gefunden', 404);
$db->execute('DELETE FROM classes WHERE id = ?', [$classId]);
Response::ok();
}
Response::error('Unbekannte Aktion');
}
Response::error('Methode nicht erlaubt', 405);
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* API: Modulfreigabe (Lehrer steuert welche Module Schueler sehen/spielen)
* GET /api/modules?class_id=X → Modulstatus fuer Klasse
* POST /api/modules {action} → Modul freigeben/sperren/starten
*/
$method = $_SERVER['REQUEST_METHOD'];
$db = Database::get();
// Alle verfuegbaren Module
$ALL_MODULES = [
['id' => 'sim-05', 'name' => 'Klimawächter', 'desc' => 'Treibhauseffekt-Simulation'],
['id' => 'sim-05-3d', 'name' => 'Klimawächter 3D', 'desc' => '3D-Version der Klimasimulation'],
['id' => 'sim-07', 'name' => 'Erdbeben', 'desc' => 'Plattentektonik und Stadtplanung'],
['id' => 'sim-09', 'name' => 'Energiemix', 'desc' => 'Energiewende planen'],
['id' => 'sim-10', 'name' => 'Lieferketten', 'desc' => 'Globale Lieferketten verstehen'],
['id' => 'sim-11', 'name' => 'Regenwald', 'desc' => 'Regenwald-Exploration'],
['id' => 'sim-12', 'name' => 'Flussmanagement', 'desc' => 'Fluss und Hochwasserschutz'],
['id' => 'sim-13', 'name' => 'Stadt & Raumplanung', 'desc' => 'Stadtsimulation mit Kacheln'],
];
if ($method === 'GET') {
$teacherId = Session::requireTeacher();
$classId = (int)($_GET['class_id'] ?? 0);
if (!$classId) Response::error('class_id erforderlich');
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
if (!$class) Response::error('Klasse nicht gefunden', 404);
// Aktuelle Freigaben laden
$settings = $db->fetchAll('SELECT module_id, mode, started_at, due_date FROM class_modules WHERE class_id = ?', [$classId]);
$settingsMap = [];
foreach ($settings as $s) $settingsMap[$s['module_id']] = $s;
// Mit allen Modulen zusammenfuehren
$result = [];
foreach ($ALL_MODULES as $mod) {
$s = $settingsMap[$mod['id']] ?? null;
$result[] = [
'id' => $mod['id'],
'name' => $mod['name'],
'desc' => $mod['desc'],
'mode' => $s['mode'] ?? 'locked',
'startedAt' => $s['started_at'] ?? null,
'dueDate' => $s['due_date'] ?? null,
];
}
Response::ok($result);
}
if ($method === 'POST') {
$teacherId = Session::requireTeacher();
$body = json_decode(file_get_contents('php://input'), true);
$action = $body['action'] ?? '';
$classId = (int)($body['classId'] ?? 0);
$moduleId = $body['moduleId'] ?? '';
if (!$classId || !$moduleId) Response::error('classId und moduleId erforderlich');
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
if (!$class) Response::error('Klasse nicht gefunden', 404);
if ($action === 'set_mode') {
$mode = $body['mode'] ?? 'locked';
if (!in_array($mode, ['locked', 'free', 'teacher_started'])) Response::error('Ungültiger Modus');
$dueDate = $body['dueDate'] ?? null;
$db->execute(
'INSERT INTO class_modules (class_id, module_id, enabled, mode, started_at, due_date)
VALUES (?, ?, ?, ?, NOW(), ?)
ON DUPLICATE KEY UPDATE mode = VALUES(mode), started_at = VALUES(started_at), due_date = VALUES(due_date), enabled = 1',
[$classId, $moduleId, 1, $mode, $dueDate]
);
$db->execute(
"INSERT INTO activity_log (user_type, user_id, action, detail) VALUES ('teacher', ?, 'set_module_mode', ?)",
[$teacherId, "$moduleId$mode"]
);
Response::ok();
}
Response::error('Unbekannte Aktion');
}
Response::error('Methode nicht erlaubt', 405);
+138
View File
@@ -0,0 +1,138 @@
<?php
/**
* API: Schueler-Verwaltung (nur fuer Lehrer)
* GET /api/students?class_id=X → Alle Schueler einer Klasse
* POST /api/students {action} → Schueler erstellen/bearbeiten/loeschen
*/
$method = $_SERVER['REQUEST_METHOD'];
$db = Database::get();
if ($method === 'GET') {
$teacherId = Session::requireTeacher();
$classId = (int)($_GET['class_id'] ?? 0);
if (!$classId) Response::error('class_id erforderlich');
// Sicherstellen dass Klasse dem Lehrer gehoert
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
if (!$class) Response::error('Klasse nicht gefunden', 404);
$students = $db->fetchAll(
'SELECT id, username, display_name, is_anonymous, created_at, last_login FROM students WHERE class_id = ? ORDER BY username',
[$classId]
);
// Letzte Ergebnisse pro Schueler
foreach ($students as &$s) {
$s['results'] = $db->fetchAll(
'SELECT module_id, score, balance_idx, completed_at FROM student_results WHERE student_id = ? ORDER BY completed_at DESC LIMIT 10',
[$s['id']]
);
$s['total_score'] = $db->fetchOne(
'SELECT AVG(score) as avg_score, COUNT(*) as count FROM student_results WHERE student_id = ?',
[$s['id']]
);
}
Response::ok($students);
}
if ($method === 'POST') {
$teacherId = Session::requireTeacher();
$body = json_decode(file_get_contents('php://input'), true);
$action = $body['action'] ?? '';
// === SCHUELER ERSTELLEN ===
if ($action === 'create') {
$classId = (int)($body['classId'] ?? 0);
$username = mb_substr(trim($body['username'] ?? ''), 0, 64);
$password = $body['password'] ?? '';
$displayName = mb_substr(trim($body['displayName'] ?? ''), 0, 128);
$isAnonymous = (bool)($body['isAnonymous'] ?? true);
if (!$classId || !$username || !$password) Response::error('classId, username und password erforderlich');
if (strlen($password) < 4) Response::error('Passwort muss mindestens 4 Zeichen lang sein');
// Klasse pruefen
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
if (!$class) Response::error('Klasse nicht gefunden', 404);
// Username eindeutig in Klasse?
$existing = $db->fetchOne('SELECT id FROM students WHERE class_id = ? AND username = ?', [$classId, $username]);
if ($existing) Response::error('Benutzername in dieser Klasse bereits vergeben');
$hash = password_hash($password, PASSWORD_DEFAULT);
$db->execute(
'INSERT INTO students (class_id, username, password, display_name, is_anonymous) VALUES (?, ?, ?, ?, ?)',
[$classId, $username, $hash, $displayName ?: null, $isAnonymous ? 1 : 0]
);
Response::ok(['id' => (int)$db->lastInsertId()]);
}
// === MEHRERE SCHUELER AUF EINMAL ERSTELLEN ===
if ($action === 'create_batch') {
$classId = (int)($body['classId'] ?? 0);
$count = min(50, max(1, (int)($body['count'] ?? 10)));
$prefix = mb_substr(trim($body['prefix'] ?? 'schueler'), 0, 20);
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
if (!$class) Response::error('Klasse nicht gefunden', 404);
$created = [];
for ($i = 1; $i <= $count; $i++) {
$username = $prefix . str_pad($i, 2, '0', STR_PAD_LEFT);
// Einfaches Passwort generieren (4 Ziffern)
$pw = str_pad(random_int(1000, 9999), 4, '0', STR_PAD_LEFT);
$hash = password_hash($pw, PASSWORD_DEFAULT);
$existing = $db->fetchOne('SELECT id FROM students WHERE class_id = ? AND username = ?', [$classId, $username]);
if ($existing) continue; // Ueberspringen wenn schon vorhanden
$db->execute(
'INSERT INTO students (class_id, username, password, display_name, is_anonymous) VALUES (?, ?, ?, ?, 1)',
[$classId, $username, $hash, $username]
);
$created[] = ['username' => $username, 'password' => $pw, 'id' => (int)$db->lastInsertId()];
}
Response::ok(['created' => $created]);
}
// === SCHUELER BEARBEITEN ===
if ($action === 'update') {
$studentId = (int)($body['studentId'] ?? 0);
$displayName = mb_substr(trim($body['displayName'] ?? ''), 0, 128);
$newPassword = $body['newPassword'] ?? '';
// Schueler + Klasse pruefen
$student = $db->fetchOne(
'SELECT s.id, s.class_id FROM students s JOIN classes c ON c.id = s.class_id WHERE s.id = ? AND c.teacher_id = ?',
[$studentId, $teacherId]
);
if (!$student) Response::error('Schüler nicht gefunden', 404);
if ($displayName) {
$db->execute('UPDATE students SET display_name = ? WHERE id = ?', [$displayName, $studentId]);
}
if ($newPassword && strlen($newPassword) >= 4) {
$db->execute('UPDATE students SET password = ? WHERE id = ?', [password_hash($newPassword, PASSWORD_DEFAULT), $studentId]);
}
Response::ok();
}
// === SCHUELER LOESCHEN ===
if ($action === 'delete') {
$studentId = (int)($body['studentId'] ?? 0);
$student = $db->fetchOne(
'SELECT s.id FROM students s JOIN classes c ON c.id = s.class_id WHERE s.id = ? AND c.teacher_id = ?',
[$studentId, $teacherId]
);
if (!$student) Response::error('Schüler nicht gefunden', 404);
$db->execute('DELETE FROM students WHERE id = ?', [$studentId]);
Response::ok();
}
Response::error('Unbekannte Aktion');
}
Response::error('Methode nicht erlaubt', 405);
+72
View File
@@ -0,0 +1,72 @@
-- GeoGraSim — Schema V2: Vollstaendiges User Management
-- Fuehre NACH schema.sql aus: mysql -u root geograsim < schema-v2.sql
USE geograsim;
-- Lehrkraefte erweitern um E-Mail (ignoriere Fehler wenn Spalte schon existiert)
ALTER TABLE teachers ADD COLUMN email VARCHAR(255) UNIQUE AFTER username;
ALTER TABLE teachers ADD COLUMN email_verified BOOLEAN DEFAULT FALSE AFTER email;
ALTER TABLE teachers ADD COLUMN school_name VARCHAR(255) AFTER display_name;
ALTER TABLE teachers ADD COLUMN reset_token VARCHAR(64) AFTER email_verified;
ALTER TABLE teachers ADD COLUMN reset_expires TIMESTAMP NULL AFTER reset_token;
-- Schueler als echte Accounts (nicht nur Sessions)
CREATE TABLE IF NOT EXISTS students (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
class_id INT UNSIGNED NOT NULL,
username VARCHAR(64) NOT NULL,
password VARCHAR(255) NOT NULL,
display_name VARCHAR(128),
is_anonymous BOOLEAN DEFAULT TRUE, -- true = Pseudonym, false = echter Name
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP NULL,
UNIQUE KEY uq_class_user (class_id, username),
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Lizenzen
CREATE TABLE IF NOT EXISTS licenses (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(32) NOT NULL UNIQUE,
teacher_id INT UNSIGNED, -- NULL = noch nicht eingeloest
max_students INT UNSIGNED DEFAULT 30,
valid_from DATE NOT NULL,
valid_until DATE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
redeemed_at TIMESTAMP NULL,
FOREIGN KEY (teacher_id) REFERENCES teachers(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- Modulfreigabe erweitern
ALTER TABLE class_modules ADD COLUMN mode ENUM('locked','free','teacher_started') DEFAULT 'locked' AFTER enabled;
ALTER TABLE class_modules ADD COLUMN started_at TIMESTAMP NULL AFTER mode;
ALTER TABLE class_modules ADD COLUMN due_date TIMESTAMP NULL AFTER started_at;
-- Schueler-Ergebnisse (verknuepft mit Schueler-Account, nicht nur Session)
CREATE TABLE IF NOT EXISTS student_results (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
student_id INT UNSIGNED NOT NULL,
class_id INT UNSIGNED NOT NULL,
module_id VARCHAR(16) NOT NULL,
score DECIMAL(5,2), -- 0-100
balance_idx DECIMAL(5,2), -- 0-100
duration_sec INT UNSIGNED,
detail_json JSON, -- Modulspezifische Details
completed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_student (student_id),
INDEX idx_class_module (class_id, module_id),
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Aktivitaetslog (wer hat wann was gemacht)
CREATE TABLE IF NOT EXISTS activity_log (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_type ENUM('teacher','student') NOT NULL,
user_id INT UNSIGNED NOT NULL,
action VARCHAR(64) NOT NULL, -- 'login','start_module','complete_module','view_results'
detail VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_type, user_id),
INDEX idx_time (created_at)
) ENGINE=InnoDB;
+268
View File
@@ -0,0 +1,268 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stadt-Editor</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:sans-serif; background:#ddd; display:flex; height:100vh; }
#sidebar { width:140px; background:#fff; padding:8px; overflow-y:auto; }
#sidebar h3 { font-size:10px; color:#888; margin-bottom:6px; }
.btn { display:block; width:100%; padding:4px; margin-bottom:4px; border:2px solid #ccc; border-radius:4px; background:#fff; cursor:pointer; font-size:10px; text-align:center; }
.btn.active { border-color:#4a7c8a; background:#e0f0f4; }
#main { flex:1; display:flex; align-items:center; justify-content:center; overflow:hidden; }
#main canvas { max-width:100%; max-height:100%; }
</style>
</head>
<body>
<div id="sidebar">
<h3>Kacheln</h3>
<button class="btn" data-t="0">Gerade</button>
<button class="btn" data-t="1">Kurve</button>
<button class="btn" data-t="2">T-Kreuzung</button>
<button class="btn" data-t="3">Kreuzung</button>
<button class="btn" data-t="4">Sackgasse</button>
<br>
<button class="btn" id="btnClear">🗑 Leeren</button>
</div>
<div id="main">
<canvas id="cv"></canvas>
</div>
<script>
// === CONFIG ===
var COLS = 8, ROWS = 8, TS = 72
var GREEN = '#b8ccaa', ROAD = '#2e2e2e', WHITE = '#fff'
// Tile-Typen: conn = [N,E,S,W]
var TYPES = [
[1,0,1,0], // Gerade
[1,1,0,0], // Kurve
[0,1,1,1], // T-Kreuzung
[1,1,1,1], // Kreuzung
[1,0,0,0], // Sackgasse
]
// Grid: -1 = leer, sonst {t: type-index, r: rotation 0-3}
var grid = []
for (var i = 0; i < ROWS; i++) {
grid[i] = []
for (var j = 0; j < COLS; j++) grid[i][j] = null
}
var sel = -1 // selected type
// === SIDEBAR ===
var btns = document.querySelectorAll('.btn[data-t]')
btns.forEach(function(b) {
b.addEventListener('click', function() {
var idx = parseInt(this.dataset.t)
sel = (sel === idx) ? -1 : idx
btns.forEach(function(x) { x.className = 'btn' })
if (sel >= 0) this.className = 'btn active'
})
})
document.getElementById('btnClear').addEventListener('click', function() {
for (var i = 0; i < ROWS; i++) for (var j = 0; j < COLS; j++) grid[i][j] = null
})
// === CANVAS ===
var cv = document.getElementById('cv')
var ctx = cv.getContext('2d')
cv.width = COLS * TS
cv.height = ROWS * TS
// Rotate connections: [N,E,S,W] clockwise by n
function rotConn(conn, n) {
var c = conn.slice()
for (var i = 0; i < n; i++) c = [c[3], c[0], c[1], c[2]]
return c
}
// === DRAW ONE TILE at pixel position (px, py) ===
function drawTile(px, py, conn, alpha) {
var S = TS, RW = S * 0.4, M = S / 2, HR = RW / 2
var lw = 2
var N = conn[0], E = conn[1], So = conn[2], W = conn[3]
var sum = N + E + So + W
if (alpha < 1) ctx.globalAlpha = alpha
// Green base
ctx.fillStyle = GREEN
ctx.fillRect(px, py, S, S)
// --- SACKGASSE ---
if (sum === 1) {
ctx.fillStyle = ROAD
var stubY = 4
if (N) { ctx.fillRect(px+M-HR, py, RW, stubY); ctx.beginPath(); ctx.arc(px+M, py+stubY, HR, 0, Math.PI); ctx.fill() }
if (So) { ctx.fillRect(px+M-HR, py+S-stubY, RW, stubY); ctx.beginPath(); ctx.arc(px+M, py+S-stubY, HR, Math.PI, 0); ctx.fill() }
if (E) { ctx.fillRect(px+S-stubY, py+M-HR, stubY, RW); ctx.beginPath(); ctx.arc(px+S-stubY, py+M, HR, Math.PI*1.5, Math.PI*0.5); ctx.fill() }
if (W) { ctx.fillRect(px, py+M-HR, stubY, RW); ctx.beginPath(); ctx.arc(px+stubY, py+M, HR, Math.PI*0.5, Math.PI*1.5); ctx.fill() }
// Rand
ctx.strokeStyle = WHITE; ctx.lineWidth = lw
if (N) { ctx.beginPath(); ctx.moveTo(px+M-HR,py); ctx.lineTo(px+M-HR,py+stubY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px+M+HR,py); ctx.lineTo(px+M+HR,py+stubY); ctx.stroke(); ctx.beginPath(); ctx.arc(px+M,py+stubY,HR,0,Math.PI); ctx.stroke() }
if (So) { ctx.beginPath(); ctx.moveTo(px+M-HR,py+S); ctx.lineTo(px+M-HR,py+S-stubY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px+M+HR,py+S); ctx.lineTo(px+M+HR,py+S-stubY); ctx.stroke(); ctx.beginPath(); ctx.arc(px+M,py+S-stubY,HR,Math.PI,0); ctx.stroke() }
if (E) { ctx.beginPath(); ctx.moveTo(px+S,py+M-HR); ctx.lineTo(px+S-stubY,py+M-HR); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px+S,py+M+HR); ctx.lineTo(px+S-stubY,py+M+HR); ctx.stroke(); ctx.beginPath(); ctx.arc(px+S-stubY,py+M,HR,Math.PI*1.5,Math.PI*0.5); ctx.stroke() }
if (W) { ctx.beginPath(); ctx.moveTo(px,py+M-HR); ctx.lineTo(px+stubY,py+M-HR); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px,py+M+HR); ctx.lineTo(px+stubY,py+M+HR); ctx.stroke(); ctx.beginPath(); ctx.arc(px+stubY,py+M,HR,Math.PI*0.5,Math.PI*1.5); ctx.stroke() }
ctx.globalAlpha = 1; return
}
// --- KURVE ---
var isCurve = sum === 2 && ((N&&E)||(E&&So)||(So&&W)||(W&&N))
if (isCurve) {
var cx, cy, startA, endA
if (N&&E) { cx=px+M+HR; cy=py+M-HR; startA=Math.PI; endA=Math.PI*0.5 }
if (E&&So) { cx=px+M+HR; cy=py+M+HR; startA=Math.PI*1.5; endA=Math.PI }
if (So&&W) { cx=px+M-HR; cy=py+M+HR; startA=0; endA=Math.PI*1.5 }
if (W&&N) { cx=px+M-HR; cy=py+M-HR; startA=Math.PI*0.5; endA=0 }
// Asphalt gerade Stuecke
ctx.fillStyle = ROAD
if (N) ctx.fillRect(px+M-HR, py, RW, M-HR)
if (So) ctx.fillRect(px+M-HR, py+M+HR, RW, M-HR)
if (E) ctx.fillRect(px+M+HR, py+M-HR, M-HR, RW)
if (W) ctx.fillRect(px, py+M-HR, M-HR, RW)
// Asphalt Viertelkreis
ctx.beginPath(); ctx.moveTo(cx,cy); ctx.arc(cx,cy,RW,startA,endA,true); ctx.closePath(); ctx.fill()
// Raender
ctx.strokeStyle = WHITE; ctx.lineWidth = lw
ctx.beginPath(); ctx.arc(cx,cy,RW,startA,endA,true); ctx.stroke() // Aussenbogen
// Gerade Raender beider Arme (innen + aussen)
if(N){ctx.beginPath();ctx.moveTo(px+M-HR,py);ctx.lineTo(px+M-HR,cy);ctx.stroke();ctx.beginPath();ctx.moveTo(px+M+HR,py);ctx.lineTo(px+M+HR,cy);ctx.stroke()}
if(So){ctx.beginPath();ctx.moveTo(px+M-HR,cy);ctx.lineTo(px+M-HR,py+S);ctx.stroke();ctx.beginPath();ctx.moveTo(px+M+HR,cy);ctx.lineTo(px+M+HR,py+S);ctx.stroke()}
if(E){ctx.beginPath();ctx.moveTo(cx,py+M-HR);ctx.lineTo(px+S,py+M-HR);ctx.stroke();ctx.beginPath();ctx.moveTo(cx,py+M+HR);ctx.lineTo(px+S,py+M+HR);ctx.stroke()}
if(W){ctx.beginPath();ctx.moveTo(px,py+M-HR);ctx.lineTo(cx,py+M-HR);ctx.stroke();ctx.beginPath();ctx.moveTo(px,py+M+HR);ctx.lineTo(cx,py+M+HR);ctx.stroke()}
// Mittellinie (ein Pfad)
ctx.setLineDash([TS*0.07, TS*0.05]); ctx.lineDashOffset = -TS*0.035
ctx.beginPath()
if(N&&E){ctx.moveTo(px+M,py);ctx.lineTo(px+M,cy);ctx.arc(cx,cy,HR,Math.PI,Math.PI*0.5,true);ctx.lineTo(px+S,py+M)}
if(E&&So){ctx.moveTo(px+S,py+M);ctx.lineTo(cx,py+M);ctx.arc(cx,cy,HR,Math.PI*1.5,Math.PI,true);ctx.lineTo(px+M,py+S)}
if(So&&W){ctx.moveTo(px+M,py+S);ctx.lineTo(px+M,cy);ctx.arc(cx,cy,HR,0,Math.PI*1.5,true);ctx.lineTo(px,py+M)}
if(W&&N){ctx.moveTo(px,py+M);ctx.lineTo(cx,py+M);ctx.arc(cx,cy,HR,Math.PI*0.5,0,true);ctx.lineTo(px+M,py)}
ctx.stroke(); ctx.setLineDash([]); ctx.lineDashOffset=0
ctx.globalAlpha = 1; return
}
// --- GERADE / T / KREUZUNG ---
ctx.fillStyle = ROAD
if (N) ctx.fillRect(px+M-HR, py, RW, M+HR)
if (So) ctx.fillRect(px+M-HR, py+M-HR, RW, M+HR)
if (E) ctx.fillRect(px+M-HR, py+M-HR, M+HR, RW)
if (W) ctx.fillRect(px, py+M-HR, M+HR, RW)
// Raender
ctx.strokeStyle = WHITE; ctx.lineWidth = lw
if(N||So){ctx.beginPath();ctx.moveTo(px+M-HR,N?py:py+M-HR);ctx.lineTo(px+M-HR,So?py+S:py+M+HR);ctx.stroke();ctx.beginPath();ctx.moveTo(px+M+HR,N?py:py+M-HR);ctx.lineTo(px+M+HR,So?py+S:py+M+HR);ctx.stroke()}
if(E||W){ctx.beginPath();ctx.moveTo(W?px:px+M-HR,py+M-HR);ctx.lineTo(E?px+S:px+M+HR,py+M-HR);ctx.stroke();ctx.beginPath();ctx.moveTo(W?px:px+M-HR,py+M+HR);ctx.lineTo(E?px+S:px+M+HR,py+M+HR);ctx.stroke()}
// Raender im Kreuzungsbereich entfernen
if((N||So)&&(E||W)){ctx.fillStyle=ROAD;ctx.fillRect(px+M-HR+1,py+M-HR-1,RW-2,lw+2);ctx.fillRect(px+M-HR+1,py+M+HR-lw,RW-2,lw+2);ctx.fillRect(px+M-HR-1,py+M-HR+1,lw+2,RW-2);ctx.fillRect(px+M+HR-lw,py+M-HR+1,lw+2,RW-2)}
// Mittellinien
ctx.strokeStyle=WHITE;ctx.lineWidth=lw;ctx.setLineDash([TS*0.07,TS*0.05]);ctx.lineDashOffset=-TS*0.035
if(sum===2){// Gerade
if(N||So){ctx.beginPath();ctx.moveTo(px+M,py);ctx.lineTo(px+M,py+S);ctx.stroke()}
if(E||W){ctx.beginPath();ctx.moveTo(px,py+M);ctx.lineTo(px+S,py+M);ctx.stroke()}
} else if(sum===3){// T: Querbalken durchgehend, Arm mit Stopplinie
if(!N){ctx.beginPath();ctx.moveTo(px,py+M);ctx.lineTo(px+S,py+M);ctx.stroke();ctx.beginPath();ctx.moveTo(px+M,py+M+HR);ctx.lineTo(px+M,py+S);ctx.stroke()}
if(!So){ctx.beginPath();ctx.moveTo(px,py+M);ctx.lineTo(px+S,py+M);ctx.stroke();ctx.beginPath();ctx.moveTo(px+M,py);ctx.lineTo(px+M,py+M-HR);ctx.stroke()}
if(!W){ctx.beginPath();ctx.moveTo(px+M,py);ctx.lineTo(px+M,py+S);ctx.stroke();ctx.beginPath();ctx.moveTo(px+M+HR,py+M);ctx.lineTo(px+S,py+M);ctx.stroke()}
if(!E){ctx.beginPath();ctx.moveTo(px+M,py);ctx.lineTo(px+M,py+S);ctx.stroke();ctx.beginPath();ctx.moveTo(px,py+M);ctx.lineTo(px+M-HR,py+M);ctx.stroke()}
ctx.setLineDash([]);ctx.lineWidth=lw*2;var sb=6
if(!N){ctx.beginPath();ctx.moveTo(px+M-HR+3,py+M+HR+sb);ctx.lineTo(px+M,py+M+HR+sb);ctx.stroke()}
if(!So){ctx.beginPath();ctx.moveTo(px+M,py+M-HR-sb);ctx.lineTo(px+M+HR-3,py+M-HR-sb);ctx.stroke()}
if(!W){ctx.beginPath();ctx.moveTo(px+M+HR+sb,py+M);ctx.lineTo(px+M+HR+sb,py+M+HR-3);ctx.stroke()}
if(!E){ctx.beginPath();ctx.moveTo(px+M-HR-sb,py+M-HR+3);ctx.lineTo(px+M-HR-sb,py+M);ctx.stroke()}
ctx.lineWidth=lw
} else if(sum===4){// Kreuzung: N-S durch, E-W Stopp
ctx.beginPath();ctx.moveTo(px+M,py);ctx.lineTo(px+M,py+S);ctx.stroke()
ctx.beginPath();ctx.moveTo(px,py+M);ctx.lineTo(px+M-HR,py+M);ctx.stroke()
ctx.beginPath();ctx.moveTo(px+M+HR,py+M);ctx.lineTo(px+S,py+M);ctx.stroke()
ctx.setLineDash([]);ctx.lineWidth=lw*2;var sb=6
ctx.beginPath();ctx.moveTo(px+M-HR+sb,py+M);ctx.lineTo(px+M-HR+sb,py+M+HR-3);ctx.stroke()
ctx.beginPath();ctx.moveTo(px+M+HR-sb,py+M-HR+3);ctx.lineTo(px+M+HR-sb,py+M);ctx.stroke()
ctx.lineWidth=lw
}
ctx.setLineDash([]);ctx.lineDashOffset=0
ctx.globalAlpha = 1
}
// === RENDER ===
var hoverR = -1, hoverC = -1
function render() {
ctx.fillStyle = '#c8d0b8'
ctx.fillRect(0, 0, cv.width, cv.height)
for (var r = 0; r < ROWS; r++) {
for (var c = 0; c < COLS; c++) {
var px = c * TS, py = r * TS
var cell = grid[r][c]
if (cell) {
var conn = rotConn(TYPES[cell.t], cell.r)
drawTile(px, py, conn, 1)
} else {
ctx.fillStyle = GREEN
ctx.fillRect(px, py, TS, TS)
}
// Gitter
ctx.strokeStyle = 'rgba(0,0,0,0.08)'
ctx.lineWidth = 0.5
ctx.strokeRect(px, py, TS, TS)
// Hover
if (r === hoverR && c === hoverC && sel >= 0 && !cell) {
drawTile(px, py, TYPES[sel], 0.3)
}
}
}
requestAnimationFrame(render)
}
// === CLICK ===
cv.addEventListener('click', function(e) {
var rect = cv.getBoundingClientRect()
var mx = (e.clientX - rect.left) * (cv.width / rect.width)
var my = (e.clientY - rect.top) * (cv.height / rect.height)
var gc = Math.floor(mx / TS), gr = Math.floor(my / TS)
if (gr < 0 || gr >= ROWS || gc < 0 || gc >= COLS) return
var cell = grid[gr][gc]
// Existierende Kachel: drehen
if (cell) {
var relX = mx - gc * TS, relY = my - gr * TS
if (relY < TS * 0.35) {
if (relX < TS * 0.4) { cell.r = (cell.r + 3) % 4; return }
if (relX > TS * 0.6) { cell.r = (cell.r + 1) % 4; return }
}
return
}
// Neue Kachel setzen
if (sel >= 0) {
grid[gr][gc] = { t: sel, r: 0 }
}
})
cv.addEventListener('mousemove', function(e) {
var rect = cv.getBoundingClientRect()
var mx = (e.clientX - rect.left) * (cv.width / rect.width)
var my = (e.clientY - rect.top) * (cv.height / rect.height)
hoverC = Math.floor(mx / TS); hoverR = Math.floor(my / TS)
if (hoverR < 0 || hoverR >= ROWS || hoverC < 0 || hoverC >= COLS) { hoverR = -1; hoverC = -1 }
})
cv.addEventListener('mouseleave', function() { hoverR = -1; hoverC = -1 })
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { sel = -1; btns.forEach(function(x){x.className='btn'}) }
})
render()
</script>
</body>
</html>
+472 -43
View File
@@ -3,58 +3,487 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kachel-Test</title>
<title>Kachel-Test — Gebäude</title>
<style>
body { margin:0; display:flex; align-items:center; justify-content:center; height:100vh; background:#e8e8e0; }
canvas { background:#c8d4b0; }
body { margin:0; display:flex; align-items:flex-start; justify-content:center; min-height:100vh; background:#e0e0d8; gap:10px; flex-wrap:wrap; font-family:Inter,sans-serif; padding:15px; }
.tile { display:flex; flex-direction:column; align-items:center; gap:2px; }
.tile span { font-size:9px; color:#666; }
</style>
</head>
<body>
<canvas id="cv" width="400" height="400"></canvas>
<div class="tile"><canvas id="t01" width="200" height="200"></canvas><span>Kirche</span></div>
<div class="tile"><canvas id="t02" width="200" height="200"></canvas><span>Moschee</span></div>
<div class="tile"><canvas id="t03" width="200" height="200"></canvas><span>Wohngebiet</span></div>
<div class="tile"><canvas id="t04" width="200" height="200"></canvas><span>Fabrik</span></div>
<div class="tile"><canvas id="t05" width="200" height="200"></canvas><span>Spital</span></div>
<div class="tile"><canvas id="t06" width="200" height="200"></canvas><span>Feuerwehr</span></div>
<div class="tile"><canvas id="t07" width="200" height="200"></canvas><span>Polizei</span></div>
<div class="tile"><canvas id="t08" width="200" height="200"></canvas><span>Schule</span></div>
<div class="tile"><canvas id="t09" width="200" height="200"></canvas><span>Supermarkt</span></div>
<div class="tile"><canvas id="t10" width="200" height="200"></canvas><span>Park</span></div>
<script>
var cv = document.getElementById('cv')
var c = cv.getContext('2d')
var S = 300 // Kachelgroesse (Quadrat)
var x0 = 50 // Kachel links oben
var y0 = 50
var RW = S / 3 // Strassenbreite = 1/3 der Kachel
var SZ = 200
var RW = SZ * 0.4, M = SZ / 2, HR = RW / 2
var LW = 2
var GREEN = '#b8ccaa', ROAD = '#2e2e2e', WHITE = '#fff'
var DASH = [SZ*0.07, SZ*0.05]
var DOFF = -SZ*0.035
// === GRUENE GRUNDFLAECHE ===
c.fillStyle = '#7da84e'
c.fillRect(x0, y0, S, S)
c.strokeStyle = 'rgba(0,0,0,0.12)'
c.lineWidth = 1
c.strokeRect(x0, y0, S, S)
// === Strassen-Grundfunktionen ===
// === STRASSE NACH SUEDEN (von Mitte nach unten) ===
var roadX = x0 + S/2 - RW/2 // Strasse horizontal zentriert
var roadY = y0 + S/2 - RW/2 // Startet in der Mitte
c.fillStyle = '#3a3a3a'
c.fillRect(roadX, roadY, RW, S/2 + RW/2) // Von Mitte bis Unterkante
function base(id) {
var c = document.getElementById(id).getContext('2d')
c.fillStyle = GREEN; c.fillRect(0,0,SZ,SZ)
return c
}
// === MITTELLINIE (gestrichelt) ===
c.strokeStyle = 'rgba(255,255,180,0.7)'
c.lineWidth = 3
c.setLineDash([16, 12])
c.beginPath()
c.moveTo(x0 + S/2, y0 + S/2)
c.lineTo(x0 + S/2, y0 + S)
c.stroke()
c.setLineDash([])
function drawRoadNS(c, x) {
// Vertikale Strasse bei x-Mitte = x
c.fillStyle = ROAD; c.fillRect(x-HR, 0, RW, SZ)
c.strokeStyle = WHITE; c.lineWidth = LW
c.beginPath(); c.moveTo(x-HR,0); c.lineTo(x-HR,SZ); c.stroke()
c.beginPath(); c.moveTo(x+HR,0); c.lineTo(x+HR,SZ); c.stroke()
c.setLineDash(DASH); c.lineDashOffset = DOFF
c.beginPath(); c.moveTo(x,0); c.lineTo(x,SZ); c.stroke()
c.setLineDash([]); c.lineDashOffset = 0
}
// === RANDLINIEN (solid, weiss) ===
c.strokeStyle = 'rgba(255,255,255,0.5)'
c.lineWidth = 2
// Links
c.beginPath()
c.moveTo(roadX, roadY)
c.lineTo(roadX, y0 + S)
c.stroke()
// Rechts
c.beginPath()
c.moveTo(roadX + RW, roadY)
c.lineTo(roadX + RW, y0 + S)
c.stroke()
function drawRoadEW(c, y) {
c.fillStyle = ROAD; c.fillRect(0, y-HR, SZ, RW)
c.strokeStyle = WHITE; c.lineWidth = LW
c.beginPath(); c.moveTo(0,y-HR); c.lineTo(SZ,y-HR); c.stroke()
c.beginPath(); c.moveTo(0,y+HR); c.lineTo(SZ,y+HR); c.stroke()
c.setLineDash(DASH); c.lineDashOffset = DOFF
c.beginPath(); c.moveTo(0,y); c.lineTo(SZ,y); c.stroke()
c.setLineDash([]); c.lineDashOffset = 0
}
function drawStubN(c) {
// Sackgasse von oben — kurzer Stummel
var stubY = 4
c.fillStyle = ROAD; c.fillRect(M-HR, 0, RW, stubY)
c.beginPath(); c.arc(M, stubY, HR, 0, Math.PI); c.fill()
c.strokeStyle = WHITE; c.lineWidth = LW
c.beginPath(); c.moveTo(M-HR,0); c.lineTo(M-HR,stubY); c.stroke()
c.beginPath(); c.moveTo(M+HR,0); c.lineTo(M+HR,stubY); c.stroke()
c.beginPath(); c.arc(M, stubY, HR, 0, Math.PI); c.stroke()
}
function drawStubS(c) {
var stubY = SZ - 4
c.fillStyle = ROAD; c.fillRect(M-HR, stubY, RW, 4)
c.beginPath(); c.arc(M, stubY, HR, Math.PI, 0); c.fill()
c.strokeStyle = WHITE; c.lineWidth = LW
c.beginPath(); c.moveTo(M-HR,SZ); c.lineTo(M-HR,stubY); c.stroke()
c.beginPath(); c.moveTo(M+HR,SZ); c.lineTo(M+HR,stubY); c.stroke()
c.beginPath(); c.arc(M, stubY, HR, Math.PI, 0); c.stroke()
}
function drawStubE(c) {
var stubX = SZ - 4
c.fillStyle = ROAD; c.fillRect(stubX, M-HR, 4, RW)
c.beginPath(); c.arc(stubX, M, HR, Math.PI*1.5, Math.PI*0.5); c.fill()
c.strokeStyle = WHITE; c.lineWidth = LW
c.beginPath(); c.moveTo(SZ,M-HR); c.lineTo(stubX,M-HR); c.stroke()
c.beginPath(); c.moveTo(SZ,M+HR); c.lineTo(stubX,M+HR); c.stroke()
c.beginPath(); c.arc(stubX, M, HR, Math.PI*1.5, Math.PI*0.5); c.stroke()
}
function drawStubW(c) {
var stubX = 4
c.fillStyle = ROAD; c.fillRect(0, M-HR, 4, RW)
c.beginPath(); c.arc(stubX, M, HR, Math.PI*0.5, Math.PI*1.5); c.fill()
c.strokeStyle = WHITE; c.lineWidth = LW
c.beginPath(); c.moveTo(0,M-HR); c.lineTo(stubX,M-HR); c.stroke()
c.beginPath(); c.moveTo(0,M+HR); c.lineTo(stubX,M+HR); c.stroke()
c.beginPath(); c.arc(stubX, M, HR, Math.PI*0.5, Math.PI*1.5); c.stroke()
}
// === Gebaeude-Hilfszeichnung ===
function drawBuilding(c, x, y, w, h, color, label) {
// Gebaeude als Rechteck mit Farbe + Label
c.fillStyle = color
c.fillRect(x, y, w, h)
c.strokeStyle = 'rgba(0,0,0,0.15)'; c.lineWidth = 1
c.strokeRect(x, y, w, h)
// Dach-Effekt (dunklere Oberkante)
c.fillStyle = 'rgba(0,0,0,0.08)'
c.fillRect(x, y, w, 4)
// Label
if (label) {
c.fillStyle = 'rgba(255,255,255,0.9)'
c.font = 'bold 10px Inter, sans-serif'
c.textAlign = 'center'; c.textBaseline = 'middle'
c.fillText(label, x + w/2, y + h/2)
}
}
function drawHouse(c, x, y, w, h) {
// Kleines Haus mit Dach
c.fillStyle = '#d4a574'
c.fillRect(x, y + h*0.35, w, h*0.65)
// Dach
c.fillStyle = '#8B4513'
c.beginPath()
c.moveTo(x - 2, y + h*0.35)
c.lineTo(x + w/2, y)
c.lineTo(x + w + 2, y + h*0.35)
c.closePath()
c.fill()
// Fenster
c.fillStyle = '#a8d0e0'
c.fillRect(x + w*0.2, y + h*0.5, w*0.25, h*0.2)
// Tuer
c.fillStyle = '#6a4a3a'
c.fillRect(x + w*0.55, y + h*0.55, w*0.2, h*0.4)
}
function drawTree(c, x, y, sz) {
c.fillStyle = '#5a4020'
c.fillRect(x + sz*0.4, y + sz*0.5, sz*0.2, sz*0.5)
c.fillStyle = '#3a8a2a'
c.beginPath(); c.arc(x + sz*0.5, y + sz*0.4, sz*0.4, 0, Math.PI*2); c.fill()
}
// ============================================================
// 1. KIRCHE — Sackgasse S, grosses Gebaeude
// ============================================================
;(function(){
var c = base('t01')
drawStubS(c)
// Grosses Kirchengebaeude
drawBuilding(c, 30, 15, 140, 110, '#c8b090', '')
// Turm
c.fillStyle = '#a08060'
c.fillRect(80, 5, 40, 60)
// Kreuz oben
c.strokeStyle = '#5a4a3a'; c.lineWidth = 3
c.beginPath(); c.moveTo(100, 2); c.lineTo(100, 18); c.stroke()
c.beginPath(); c.moveTo(92, 10); c.lineTo(108, 10); c.stroke()
// Tuer
c.fillStyle = '#5a3a2a'
c.beginPath(); c.arc(100, 125, 15, Math.PI, 0); c.fill()
c.fillRect(85, 115, 30, 15)
// Label
c.fillStyle = '#5a4a3a'; c.font = 'bold 11px Inter,sans-serif'; c.textAlign = 'center'
c.fillText('Kirche', 100, 85)
})()
// ============================================================
// 2. MOSCHEE — Sackgasse N + S (zwei Zufahrten)
// ============================================================
;(function(){
var c = base('t02')
drawStubN(c)
drawStubS(c)
// Gebaeude
drawBuilding(c, 25, 35, 150, 90, '#d8c8a8', '')
// Kuppel
c.fillStyle = '#c0a878'
c.beginPath(); c.arc(100, 50, 35, Math.PI, 0); c.fill()
// Halbmond
c.fillStyle = '#e8d8a0'; c.font = '18px serif'; c.textAlign = 'center'
c.fillText('☪', 100, 42)
// Minarett
c.fillStyle = '#b8a878'
c.fillRect(150, 25, 12, 70)
c.fillRect(147, 22, 18, 6)
// Label
c.fillStyle = '#6a5a3a'; c.font = 'bold 10px Inter,sans-serif'
c.fillText('Moschee', 100, 100)
})()
// ============================================================
// 3. WOHNGEBIET — Gerade N-S, Haeuser links und rechts
// ============================================================
;(function(){
var c = base('t03')
drawRoadNS(c, M)
// Haeuser links
drawHouse(c, 8, 20, 30, 30)
drawHouse(c, 12, 70, 28, 28)
drawHouse(c, 6, 120, 32, 30)
// Haeuser rechts
drawHouse(c, 160, 15, 30, 28)
drawHouse(c, 162, 65, 28, 30)
drawHouse(c, 158, 115, 32, 28)
drawHouse(c, 164, 160, 26, 26)
})()
// ============================================================
// 4. FABRIK — Kurve (N→E), Fabrik im gruenen Bereich (unten-links)
// ============================================================
;(function(){
var c = base('t04')
// Kurve N→E
var px = M+HR, py = M-HR
c.fillStyle = ROAD
c.fillRect(M-HR, 0, RW, py)
c.fillRect(px, M-HR, SZ-px, RW)
c.beginPath(); c.moveTo(px,py); c.arc(px,py,RW,Math.PI,Math.PI*0.5,true); c.closePath(); c.fill()
// Raender
c.strokeStyle=WHITE;c.lineWidth=LW
c.beginPath();c.arc(px,py,RW,Math.PI,Math.PI*0.5,true);c.stroke()
c.beginPath();c.moveTo(M+HR,0);c.lineTo(M+HR,py);c.stroke()
c.beginPath();c.moveTo(px,M-HR);c.lineTo(SZ,M-HR);c.stroke()
c.beginPath();c.moveTo(M-HR,0);c.lineTo(M-HR,py);c.stroke()
c.beginPath();c.moveTo(px,M+HR);c.lineTo(SZ,M+HR);c.stroke()
// Mittellinie
c.setLineDash(DASH);c.lineDashOffset=DOFF
c.beginPath();c.moveTo(M,0);c.lineTo(M,py);c.arc(px,py,HR,Math.PI,Math.PI*0.5,true);c.lineTo(SZ,M);c.stroke()
c.setLineDash([]);c.lineDashOffset=0
// Fabrik unten-links
drawBuilding(c, 10, 130, 70, 55, '#8a8a8a', '')
// Schornstein
c.fillStyle = '#6a6a6a'
c.fillRect(55, 110, 12, 20)
c.fillRect(30, 115, 10, 15)
// Rauch
c.fillStyle = 'rgba(100,100,100,0.25)'
c.beginPath(); c.arc(61, 105, 6, 0, Math.PI*2); c.fill()
c.beginPath(); c.arc(58, 96, 5, 0, Math.PI*2); c.fill()
// Label
c.fillStyle = '#fff'; c.font = 'bold 10px Inter,sans-serif'; c.textAlign = 'center'
c.fillText('Fabrik', 45, 160)
})()
// ============================================================
// 5. SPITAL — Sackgasse E + W (zwei Zufahrten seitlich)
// ============================================================
;(function(){
var c = base('t05')
drawStubE(c)
drawStubW(c)
// Grosses Gebaeude
drawBuilding(c, 20, 15, 160, 80, '#e8e0d0', '')
// Rotes Kreuz
c.fillStyle = '#cc3333'
c.fillRect(90, 30, 20, 50)
c.fillRect(75, 42, 50, 20)
// Label
c.fillStyle = '#6a3a3a'; c.font = 'bold 11px Inter,sans-serif'; c.textAlign = 'center'
c.fillText('Spital', 100, 120)
// Rettungswagen-Parkplatz
c.fillStyle = '#7a7a7a'; c.fillRect(30, 100, 50, 25)
c.fillStyle = '#ddd'; c.font = '16px serif'
c.fillText('🚑', 55, 117)
})()
// ============================================================
// 6. FEUERWEHR — Sackgasse S
// ============================================================
;(function(){
var c = base('t06')
drawStubS(c)
// Gebaeude
drawBuilding(c, 25, 10, 150, 100, '#cc4444', '')
// Tore (2 Garagen)
c.fillStyle = '#8a2a2a'
c.fillRect(40, 70, 45, 40)
c.fillRect(105, 70, 45, 40)
// Tor-Linien
c.strokeStyle = '#6a1a1a'; c.lineWidth = 1
for (var i = 0; i < 5; i++) { c.beginPath(); c.moveTo(40, 75+i*7); c.lineTo(85, 75+i*7); c.stroke() }
for (var i = 0; i < 5; i++) { c.beginPath(); c.moveTo(105, 75+i*7); c.lineTo(150, 75+i*7); c.stroke() }
// Label
c.fillStyle = '#fff'; c.font = 'bold 11px Inter,sans-serif'; c.textAlign = 'center'
c.fillText('Feuerwehr', 100, 45)
// Schlauchturm
c.fillStyle = '#aa3333'
c.fillRect(160, 15, 15, 55)
})()
// ============================================================
// 7. POLIZEI — Sackgasse N
// ============================================================
;(function(){
var c = base('t07')
drawStubN(c)
// Gebaeude
drawBuilding(c, 20, 50, 160, 85, '#4a6a9a', '')
// Stern/Abzeichen
c.fillStyle = '#c0d0e8'; c.font = '24px serif'; c.textAlign = 'center'
c.fillText('⭐', 100, 85)
// Label
c.fillStyle = '#fff'; c.font = 'bold 11px Inter,sans-serif'
c.fillText('Polizei', 100, 115)
// Parkplatz
c.fillStyle = '#7a7a7a'; c.fillRect(30, 145, 60, 20)
c.fillStyle = '#ddd'; c.font = '14px serif'
c.fillText('🚔', 60, 160)
})()
// ============================================================
// 8. SCHULE — Gerade E-W, Gebaeude oben
// ============================================================
;(function(){
var c = base('t08')
drawRoadEW(c, M)
// Schulgebaeude oben
drawBuilding(c, 15, 10, 170, 60, '#e0c888', '')
// Fensterreihe
c.fillStyle = '#a8d0e0'
for (var i = 0; i < 5; i++) c.fillRect(25 + i*34, 25, 22, 18)
// Label
c.fillStyle = '#5a4a2a'; c.font = 'bold 11px Inter,sans-serif'; c.textAlign = 'center'
c.fillText('Schule', 100, 58)
// Schulhof unten (Wiese ist schon gruen, ein paar Details)
drawTree(c, 30, 145, 20)
drawTree(c, 140, 150, 18)
c.fillStyle = '#aaa'; c.fillRect(70, 155, 50, 3) // Bank
})()
// ============================================================
// 9. SUPERMARKT — Gerade N-S, Gebaeude rechts mit Parkplatz
// ============================================================
;(function(){
var c = base('t09')
drawRoadNS(c, M)
// Supermarkt rechts
drawBuilding(c, 140, 20, 55, 80, '#e0d0a0', '')
// Label
c.fillStyle = '#5a4a2a'; c.font = 'bold 9px Inter,sans-serif'; c.textAlign = 'center'
c.fillText('Super-', 167, 50)
c.fillText('markt', 167, 62)
// Parkplatz
c.fillStyle = '#8a8a8a'
c.fillRect(140, 110, 55, 40)
// Parkplatz-Linien
c.strokeStyle = '#aaa'; c.lineWidth = 1
for (var i = 0; i < 4; i++) { c.beginPath(); c.moveTo(152+i*12, 110); c.lineTo(152+i*12, 150); c.stroke() }
// Einkaufswagen
c.fillStyle = '#ddd'; c.font = '12px serif'
c.fillText('🛒', 167, 135)
})()
// ============================================================
// 10. PARK — Sackgasse N, viel Gruen
// ============================================================
;(function(){
var c = base('t10')
drawStubN(c)
// Baeume
drawTree(c, 15, 40, 28)
drawTree(c, 60, 55, 24)
drawTree(c, 130, 35, 30)
drawTree(c, 155, 80, 22)
drawTree(c, 25, 120, 26)
drawTree(c, 100, 110, 20)
drawTree(c, 150, 140, 24)
// Teich
c.fillStyle = 'rgba(60,140,200,0.4)'
c.beginPath(); c.ellipse(80, 140, 30, 18, 0.1, 0, Math.PI*2); c.fill()
c.strokeStyle = 'rgba(60,140,200,0.3)'; c.lineWidth = 1; c.stroke()
// Bank
c.fillStyle = '#8a7050'
c.fillRect(95, 82, 20, 6)
c.fillRect(95, 82, 3, 10)
c.fillRect(112, 82, 3, 10)
// Weg (kleiner Pfad)
c.strokeStyle = 'rgba(160,140,100,0.4)'; c.lineWidth = 3; c.setLineDash([4,3])
c.beginPath(); c.moveTo(M, 10); c.quadraticCurveTo(60, 80, 80, 140); c.stroke()
c.setLineDash([])
// Label
c.fillStyle = 'rgba(0,0,0,0.35)'; c.font = 'bold 11px Inter,sans-serif'; c.textAlign = 'center'
c.fillText('Park', 100, 175)
})()
// ============================================================
// EMOJI-VERSIONEN (Gegenversuch)
// ============================================================
var emojiTiles = [
{id:'e01', label:'Kirche', stub:'S', emoji:'⛪', size:95},
{id:'e02', label:'Moschee', stub:'NS', emoji:'🕌', size:85},
{id:'e03', label:'Wohngebiet', road:'NS', emojis:[
{e:'🏠',x:-0.32,y:-0.3,s:32},{e:'🏡',x:-0.3,y:0,s:30},{e:'🏠',x:-0.32,y:0.28,s:28},
{e:'🏘️',x:0.32,y:-0.25,s:30},{e:'🏠',x:0.3,y:0.05,s:32},{e:'🏡',x:0.32,y:0.3,s:28}]},
{id:'e04', label:'Fabrik', stub:'S', emoji:'🏭', size:85},
{id:'e05', label:'Spital', stub:'EW', emoji:'🏥', size:75, extra:[{e:'🚑',x:0,y:0.3,s:32}]},
{id:'e06', label:'Feuerwehr', stub:'S', bg:'rgba(200,50,40,0.12)', emojis:[
{e:'🏠',x:0,y:-0.15,s:60},{e:'🚒',x:-0.2,y:0.22,s:40},{e:'🚒',x:0.2,y:0.22,s:40}]},
{id:'e07', label:'Polizei', stub:'N', emoji:'🚔', size:45, extra:[{e:'🏢',x:0,y:0.15,s:55}]},
{id:'e08', label:'Schule', road:'EW', emoji:'🏫', size:60, yoff:-0.28},
{id:'e09', label:'Supermarkt', road:'NS', emoji:'🏪', size:55, xoff:0.3, extra:[
{e:'🚗',x:0.22,y:0.25,s:26},{e:'🚙',x:0.35,y:0.15,s:24},{e:'🚗',x:0.22,y:0.38,s:22}]},
{id:'e10', label:'Park', stub:'N', emojis:[
{e:'🌳',x:-0.3,y:-0.1,s:32},{e:'🌳',x:-0.1,y:-0.1,s:32},{e:'🌳',x:0.1,y:-0.1,s:32},{e:'🌳',x:0.3,y:-0.1,s:32},
{e:'🌳',x:-0.2,y:0.1,s:28},{e:'🌳',x:0,y:0.1,s:28},{e:'🌳',x:0.2,y:0.1,s:28},
{e:'🌷',x:-0.35,y:0.3,s:16},{e:'🌷',x:-0.2,y:0.32,s:14},{e:'🌷',x:-0.05,y:0.3,s:16},
{e:'🌷',x:0.1,y:0.32,s:14},{e:'🌷',x:0.25,y:0.3,s:16},{e:'🌷',x:0.38,y:0.32,s:14}]},
{id:'e11', label:'Rathaus', stub:'S', emoji:'🏛️', size:85},
{id:'e13', label:'Tankstelle', road:'NS', emojis:[
{e:'⛽',x:0.28,y:-0.2,s:32},{e:'⛽',x:0.28,y:0,s:32},{e:'⛽',x:0.28,y:0.2,s:32},
{e:'🚗',x:0.38,y:-0.15,s:26},{e:'🚙',x:0.38,y:0.15,s:26}]},
{id:'e14', label:'Spielplatz', stub:'N', emojis:[
{e:'🛝',x:-0.2,y:0,s:42},{e:'🎠',x:0.2,y:0,s:36},{e:'⚽',x:0,y:0.25,s:22},
{e:'🌳',x:-0.35,y:0.3,s:26},{e:'🌳',x:0.35,y:0.3,s:26}]},
{id:'e15', label:'Bauernhof', stub:'EW', emojis:[
{e:'🏠',x:0,y:-0.05,s:55},
{e:'🌾',x:-0.35,y:-0.3,s:24},{e:'🌾',x:-0.2,y:-0.3,s:24},{e:'🌾',x:-0.05,y:-0.3,s:24},
{e:'🌾',x:0.1,y:-0.3,s:24},{e:'🌾',x:0.25,y:-0.3,s:24},{e:'🌾',x:0.4,y:-0.3,s:24},
{e:'🐄',x:-0.25,y:0.28,s:30},{e:'🐔',x:0.15,y:0.3,s:22},{e:'🐔',x:0.28,y:0.28,s:22}]},
{id:'e16', label:'Hochhäuser', road:'NS', emojis:[
{e:'🏢',x:0.22,y:-0.2,s:38},{e:'🏢',x:0.32,y:-0.2,s:38},{e:'🏢',x:0.42,y:-0.2,s:38},
{e:'🏢',x:0.22,y:0.15,s:38},{e:'🏢',x:0.32,y:0.15,s:38},{e:'🏢',x:0.42,y:0.15,s:38}]},
]
// Erzeuge HTML fuer Emoji-Tiles
var hr = document.createElement('div')
hr.style.cssText = 'width:100%;text-align:center;color:#888;font-size:11px;margin:8px 0;border-top:1px solid #ccc;padding-top:6px'
hr.textContent = '▼ Emoji-Versionen ▼'
document.body.appendChild(hr)
emojiTiles.forEach(function(t) {
var div = document.createElement('div')
div.className = 'tile'
var cv = document.createElement('canvas')
cv.width = 200; cv.height = 200; cv.id = t.id
var span = document.createElement('span')
span.textContent = t.label
div.appendChild(cv); div.appendChild(span)
document.body.appendChild(div)
var c = cv.getContext('2d')
c.fillStyle = GREEN; c.fillRect(0, 0, SZ, SZ)
// Hintergrundfarbe (z.B. rot fuer Feuerwehr)
if (t.bg) { c.fillStyle = t.bg; c.fillRect(0, 0, SZ, SZ) }
// Strasse
if (t.road === 'NS') drawRoadNS(c, M)
if (t.road === 'EW') drawRoadEW(c, M)
if (t.stub) {
if (t.stub.indexOf('N') >= 0) drawStubN(c)
if (t.stub.indexOf('S') >= 0) drawStubS(c)
if (t.stub.indexOf('E') >= 0) drawStubE(c)
if (t.stub.indexOf('W') >= 0) drawStubW(c)
}
// Emojis
c.textAlign = 'center'; c.textBaseline = 'middle'
if (t.emoji) {
c.font = (t.size || 50) + 'px serif'
c.fillText(t.emoji, M + (t.xoff || 0) * SZ, M + (t.yoff || 0) * SZ)
}
if (t.emojis) {
t.emojis.forEach(function(em) {
c.font = (em.s || 24) + 'px serif'
c.fillText(em.e, M + em.x * SZ, M + em.y * SZ)
})
}
if (t.extra) {
t.extra.forEach(function(em) {
c.font = (em.s || 24) + 'px serif'
c.fillText(em.e, M + (em.x||0) * SZ, M + (em.y||0) * SZ)
})
}
// Keine Beschriftung auf der Kachel
})
</script>
</body>
</html>