Files
geograsim/App/stadt.html
T
Adminator f22c5ebbfe Stand 2026-04-13: PHP/MySQL Infrastruktur, Flussmanagement, Stadt-Prototyp
- PHP/MySQL Backend (XAMPP + Produktionsserver)
- Front-Controller, API-Endpunkte, Session-Management
- Flussmanagement-Simulation (Echtzeit, Punkt-basierter Fluss)
- Stadt & Raumplanung (Prototyp, Top-Down Kachelsystem)
- Klimawaechter 3D: Deiche kleiner, Baeume kippen, Budget angepasst
- persistence.ts: Dualer Speicher (localStorage + Server-API)
- 6 Unit-Test-Dateien fuer bestehende Simulationen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:43:42 +02:00

451 lines
23 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Stadt & Raumplanung</title>
<link rel="icon" type="image/png" href="assets/img/logo.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#f4f3ef;--white:#fff;--text:#1a1a1a;--text2:#4a4a4a;--text3:#8a8a8a;--fjord:#4a7c8a;--fjord-l:#dae8ec;--fjord-d:#3a6470;--moss:#5a8a5e;--sand:#c4a35a;--coral:#c07a6b;--coral-l:#f2ddd8}
html,body{height:100%;overflow:hidden}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);display:flex;flex-direction:column}
.nav{padding:.4rem .8rem;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid rgba(0,0,0,.05);background:var(--white);flex-shrink:0}
.nav-brand{display:flex;align-items:center;gap:.4rem;font-weight:800;font-size:.9rem;color:var(--fjord);text-decoration:none}
.nav-brand img{width:24px;height:24px;border-radius:5px}
.nav-btn{padding:.25rem .5rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:var(--white);color:var(--text2);font-size:.65rem;font-weight:600;cursor:pointer;text-decoration:none}
.main{flex:1;display:grid;grid-template-columns:160px 1fr 180px;gap:.3rem;padding:.3rem;min-height:0;overflow:hidden}
.panel{background:var(--white);border-radius:8px;border:1px solid rgba(0,0,0,.04);padding:.4rem;box-shadow:0 1px 3px rgba(0,0,0,.02);overflow-y:auto}
.panel-h{font-size:.54rem;font-weight:700;color:var(--fjord);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem}
.col-left,.col-right{display:flex;flex-direction:column;gap:.3rem;overflow-y:auto}
.col-center{display:flex;flex-direction:column;gap:.25rem;min-height:0}
.topbar{flex-shrink:0;display:flex;align-items:center;gap:.4rem;background:var(--white);border-radius:7px;padding:.25rem .5rem;border:1px solid rgba(0,0,0,.04)}
.topbar .stat{font-size:.65rem;font-weight:700;display:flex;align-items:center;gap:.15rem}
.topbar .v{font-size:.8rem}
.topbar .gold .v{color:var(--sand)}
.topbar .rnd{font-size:.62rem;font-weight:700;color:var(--fjord);margin-left:auto;background:var(--fjord-l);padding:.1rem .4rem;border-radius:4px}
.btn-next{padding:.25rem .6rem;border:none;border-radius:5px;background:var(--fjord);color:#fff;font-size:.65rem;font-weight:700;cursor:pointer;margin-left:.2rem}
.canvas-wrap{flex:1;background:#92aa6a;border-radius:8px;overflow:hidden;position:relative;min-height:200px;cursor:pointer}
.canvas-wrap canvas{width:100%;height:100%;display:block}
.param{display:flex;align-items:center;gap:.2rem;padding:.15rem 0;border-bottom:1px solid rgba(0,0,0,.02)}.param:last-child{border:none}
.param-i{font-size:.68rem;width:15px;text-align:center}.param-inf{flex:1}
.param-n{font-size:.5rem;color:var(--text3)}.param-bar{height:3px;background:#e8e5dc;border-radius:1.5px;margin-top:1px;overflow:hidden}
.param-bar-f{height:100%;border-radius:1.5px;transition:width .4s}.param-v{font-size:.58rem;font-weight:700;min-width:15px;text-align:right}
.tb{display:flex;align-items:center;gap:.2rem;padding:.22rem .3rem;border:1.5px solid rgba(0,0,0,.04);border-radius:5px;background:var(--white);cursor:pointer;width:100%;text-align:left;margin-bottom:.1rem;transition:all .1s;font-size:.55rem}
.tb:hover{border-color:var(--fjord);background:var(--fjord-l)}.tb.on{border-color:var(--fjord);background:var(--fjord);color:#fff}
.tb.off{opacity:.2;pointer-events:none;filter:grayscale(100%)}
.tb .te{font-size:.75rem;width:16px;text-align:center}.tb .tn{flex:1;font-weight:600}.tb .tc{font-weight:600;color:var(--sand);font-size:.48rem}
.msg{font-size:.52rem;padding:.15rem .25rem;border-radius:4px;margin-bottom:.08rem;background:var(--fjord-l);color:var(--fjord-d);line-height:1.25}
.msg.warn{background:var(--coral-l);color:var(--coral)}.msg.good{background:rgba(90,138,94,.12);color:var(--moss)}
.ov{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;z-index:200;backdrop-filter:blur(3px)}
.ov-c{background:var(--white);border-radius:12px;padding:1.2rem;max-width:380px;width:90%;text-align:center}
.ov-c h2{font-size:.95rem;font-weight:800;margin-bottom:.15rem}.ov-c p{font-size:.7rem;color:var(--text2);margin-bottom:.4rem;line-height:1.35}
.ov-btn{padding:.3rem .8rem;border:none;border-radius:5px;background:var(--fjord);color:#fff;font-size:.7rem;font-weight:700;cursor:pointer}
.hidden{display:none!important}
@media(max-width:800px){.main{grid-template-columns:1fr}}
</style>
</head>
<body>
<nav class="nav">
<a href="index.html" class="nav-brand"><img src="assets/img/logo.png" alt="">Stadt & Raumplanung</a>
<div style="display:flex;gap:.15rem"><button id="btn-reset" class="nav-btn">🔄</button><a href="index.html" class="nav-btn"></a></div>
</nav>
<div id="game" class="main">
<div class="col-left">
<div class="panel"><div class="panel-h">Kennwerte</div><div id="params"></div></div>
<div class="panel"><div class="panel-h">Verlauf</div><canvas id="graph" style="width:100%;height:65px;display:block;border-radius:4px;background:#f0eeea"></canvas></div>
</div>
<div class="col-center">
<div class="topbar">
<div class="stat gold">💰 <span class="v" id="h-bud">0</span></div>
<span class="rnd" id="h-rnd">1/10</span>
<button class="btn-next" id="btn-next">▶ Runde</button>
</div>
<div class="canvas-wrap"><canvas id="cv"></canvas></div>
</div>
<div class="col-right">
<div class="panel"><div class="panel-h">Bauen</div><div id="tiles"></div></div>
<div class="panel"><div class="panel-h">Nachrichten</div><div id="msgs"></div></div>
</div>
</div>
<div id="ov-intro" class="ov"><div class="ov-c">
<h2>🏘️ Stadt & Raumplanung</h2>
<p>Baue eine Siedlung auf isometrischen Kacheln. Jede Kachel hat Straßen eingebaut. Achte darauf, dass sie zusammenpassen!</p>
<button class="ov-btn" id="btn-go">Los geht's!</button>
</div></div>
<div id="ov-end" class="ov hidden"><div class="ov-c"><h2 id="end-t"></h2><p id="end-p"></p><button class="ov-btn" id="btn-again">Nochmal</button></div></div>
<script>
// =====================================================================
// STADT & RAUMPLANUNG — Isometrische Kacheln (SimCity-Stil)
// Template-basiertes Kachel-System
// =====================================================================
const GW=10, GH=10
const RW = 0.30 // Strassenbreite relativ zur Kachel
// === TILE TEMPLATE SYSTEM ===
// Jede Kachel wird durch ein Template definiert:
// conn: [N,E,S,W] 1=Strasse verbindet an dieser Kante
// stub: [N,E,S,W] 0-1, wie weit die Zufahrt reinragt (0=keine, 0.1=kurz/Parkplatz, 0.5=halb, 1=voll)
// content: Array von {emoji, ox, oy} relativ zum Kachel-Zentrum (-0.5..0.5)
// eff: Effekte auf Simulation
const TILES = {
empty: {n:'Leer', cost:0, conn:[0,0,0,0], stub:[0,0,0,0], content:[], eff:{}},
// Strassen
road_ns: {n:'Straße N-S', cost:2, conn:[1,0,1,0], stub:[1,0,1,0], content:[], eff:{traf:1,env:-1}},
road_ew: {n:'Straße O-W', cost:2, conn:[0,1,0,1], stub:[0,1,0,1], content:[], eff:{traf:1,env:-1}},
road_cross: {n:'Kreuzung', cost:3, conn:[1,1,1,1], stub:[1,1,1,1], content:[], eff:{traf:2,env:-1}},
road_t_ews: {n:'T offen S', cost:3, conn:[0,1,1,1], stub:[0,1,1,1], content:[], eff:{traf:1,env:-1}},
road_t_new: {n:'T offen W', cost:3, conn:[1,1,0,1], stub:[1,1,0,1], content:[], eff:{traf:1,env:-1}},
road_t_nes: {n:'T offen O', cost:3, conn:[1,1,1,0], stub:[1,1,1,0], content:[], eff:{traf:1,env:-1}},
road_t_nsw: {n:'T offen N', cost:3, conn:[1,0,1,1], stub:[1,0,1,1], content:[], eff:{traf:1,env:-1}},
road_ne: {n:'Kurve N→O', cost:2, conn:[1,1,0,0], stub:[1,1,0,0], content:[], eff:{traf:1,env:-1}},
road_es: {n:'Kurve O→S', cost:2, conn:[0,1,1,0], stub:[0,1,1,0], content:[], eff:{traf:1,env:-1}},
road_sw: {n:'Kurve S→W', cost:2, conn:[0,0,1,1], stub:[0,0,1,1], content:[], eff:{traf:1,env:-1}},
road_wn: {n:'Kurve W→N', cost:2, conn:[1,0,0,1], stub:[1,0,0,1], content:[], eff:{traf:1,env:-1}},
// Gebaeude — Strasse geht durch, Gebaeude am Rand
housing_ns: {n:'Wohngebiet │', cost:6, conn:[1,0,1,0], stub:[1,0,1,0],
content:[{e:'🏠',ox:-0.3,oy:-0.1},{e:'🏡',ox:0.3,oy:0.1},{e:'🏠',ox:0.3,oy:-0.15}], eff:{eco:2,sat:2,env:-1,traf:1}},
housing_ew: {n:'Wohngebiet ─', cost:6, conn:[0,1,0,1], stub:[0,1,0,1],
content:[{e:'🏠',ox:-0.1,oy:-0.3},{e:'🏡',ox:0.1,oy:0.3}], eff:{eco:2,sat:2,env:-1,traf:1}},
shop_s: {n:'Laden ↓', cost:7, conn:[0,0,1,0], stub:[0,0,0.3,0],
content:[{e:'🏪',ox:0,oy:-0.15}], eff:{eco:4,sat:2,env:-1,traf:2,sup:5}},
shop_n: {n:'Laden ↑', cost:7, conn:[1,0,0,0], stub:[0.3,0,0,0],
content:[{e:'🏪',ox:0,oy:0.1}], eff:{eco:4,sat:2,env:-1,traf:2,sup:5}},
park_n: {n:'Park ↑', cost:4, conn:[1,0,0,0], stub:[0.1,0,0,0],
content:[{e:'🌳',ox:-0.15,oy:0.05},{e:'🌲',ox:0.2,oy:-0.1},{e:'🌷',ox:0,oy:0.2,s:0.6}], eff:{sat:4,env:6,sup:1}},
park_s: {n:'Park ↓', cost:4, conn:[0,0,1,0], stub:[0,0,0.1,0],
content:[{e:'🌳',ox:-0.15,oy:-0.1},{e:'🌲',ox:0.2,oy:0.05},{e:'🌷',ox:0,oy:-0.2,s:0.6}], eff:{sat:4,env:6,sup:1}},
school_es: {n:'Schule O↓', cost:9, conn:[0,1,1,0], stub:[0,0.3,0.3,0],
content:[{e:'🏫',ox:-0.15,oy:-0.15}], eff:{eco:1,sat:5,traf:1,sup:4}},
industry_sw: {n:'Industrie S←', cost:8, conn:[0,0,1,1], stub:[0,0,0.5,0.5],
content:[{e:'🏭',ox:0.1,oy:-0.15}], eff:{eco:6,sat:-3,env:-6,traf:4}},
}
function getConn(type){return TILES[type]?.conn||[0,0,0,0]}
// === GAME STATE ===
let grid=[] // GH x GW tile type strings
let level=null,round=0,budget=0,gameOver=false,selType=null
let scores={eco:40,sat:40,env:55,traf:15,sup:15,reach:25}
let messages=[],history=[],ghostR=-1,ghostC=-1
const LEVELS=[{
id:'L1',rounds:10,budget:40,income:6,
unlocked:['road_ns','road_ew','road_cross','road_t_ews','road_t_new','road_t_nes','road_t_nsw','road_ne','road_es','road_sw','road_wn','housing_ns','housing_ew','shop_s','shop_n','park_n','park_s'],
blockBad:false,
initial:[{x:4,y:4,t:'road_ew'},{x:5,y:4,t:'road_ew'},{x:6,y:4,t:'road_ew'},{x:4,y:3,t:'housing_ns'},{x:6,y:5,t:'housing_ns'}],
events:[{r:1,m:'Deine Siedlung braucht einen Laden!'},{r:4,m:'Mehr Wohnraum nötig!'},{r:7,m:'Ein Park erhöht die Zufriedenheit.'},{r:9,m:'Letzte Runde!'}],
win:50
}]
function initGame(lvl){
level=lvl;round=1;budget=lvl.budget;gameOver=false;selType=null;messages=[];history=[]
grid=[];for(let r=0;r<GH;r++){grid[r]=[];for(let c=0;c<GW;c++)grid[r][c]='empty'}
for(const t of lvl.initial)grid[t.y][t.x]=t.t
recalc();const ev=lvl.events.find(e=>e.r===1);if(ev)addMsg(ev.m,'')
}
// === SIM ===
function recalc(){
let eco=30,sat=35,env=55,traf=10,sup=10,reach=20,bad=0,iso=0
for(let r=0;r<GH;r++)for(let c=0;c<GW;c++){
const type=grid[r][c];if(type==='empty')continue
const def=TILES[type],ef=def.eff||{}
eco+=ef.eco||0;sat+=ef.sat||0;env+=ef.env||0;traf+=ef.traf||0;sup+=ef.sup||0
const conn=def.conn
const dirs=[[-1,0,0,2],[0,1,1,3],[1,0,2,0],[0,-1,3,1]]
let anyGood=false
for(const[dr,dc,myD,thD]of dirs){
if(!conn[myD])continue
const nr=r+dr,nc=c+dc
if(nr<0||nr>=GH||nc<0||nc>=GW){bad++;continue}
const nb=grid[nr][nc];if(nb==='empty'){bad++;continue}
if(TILES[nb].conn[thD])anyGood=true;else bad++
}
if(def.content.length>0&&!anyGood){iso++;reach-=4}else if(def.content.length>0)reach+=3
// Neighbors
if(type.startsWith('housing')){const nb=nbT(r,c);if(nb.some(n=>n.startsWith('park')))sat+=2;if(nb.some(n=>n.startsWith('industry'))){sat-=4;env-=3};if(nb.some(n=>n.startsWith('shop')))sup+=2}
}
traf+=bad;reach-=iso*3
const cl=v=>Math.min(100,Math.max(0,Math.round(v)))
scores={eco:cl(eco),sat:cl(sat),env:cl(env),traf:cl(traf),sup:cl(sup),reach:cl(reach)}
}
function nbT(r,c){const res=[];if(r>0)res.push(grid[r-1][c]);if(c<GW-1)res.push(grid[r][c+1]);if(r<GH-1)res.push(grid[r+1][c]);if(c>0)res.push(grid[r][c-1]);return res}
function place(r,c){
if(gameOver||!selType)return
if(grid[r][c]!=='empty'){addMsg('❌ Belegt!','warn');return}
if(budget<TILES[selType].cost){addMsg('❌ Kein Budget!','warn');return}
if(level.blockBad){
const conn=TILES[selType].conn,dirs=[[-1,0,0,2],[0,1,1,3],[1,0,2,0],[0,-1,3,1]]
for(const[dr,dc,myD,thD]of dirs){
if(!conn[myD])continue;const nr=r+dr,nc=c+dc
if(nr>=0&&nr<GH&&nc>=0&&nc<GW&&grid[nr][nc]!=='empty'&&!TILES[grid[nr][nc]].conn[thD]){addMsg('❌ Straße passt nicht!','warn');return}
}
}
grid[r][c]=selType;budget-=TILES[selType].cost;recalc();renderAll()
}
function addMsg(t,type){messages.unshift({text:t,type});if(messages.length>10)messages.pop()}
function nextRound(){
if(gameOver)return;history.push({round,...scores});round++;budget+=level.income
if(round>level.rounds){
gameOver=true;const total=Math.round(0.22*scores.eco+0.24*scores.sat+0.18*scores.env+0.16*scores.sup+0.12*scores.reach+0.08*(100-scores.traf))
document.getElementById('end-t').textContent=total>=level.win?'🎉 Geschafft!':'😔 Nicht bestanden'
document.getElementById('end-p').textContent=`Score: ${total}`;document.getElementById('ov-end').classList.remove('hidden');return
}
const ev=level.events.find(e=>e.r===round);if(ev)addMsg(ev.m,'');renderAll()
}
// === ISOMETRIC RENDERER ===
const $cv=document.getElementById('cv'),ctx=$cv.getContext('2d')
let W=0,H=0,TW=0,TH=0,mapOx=0,mapOy=0,animT=0
// Vehicles array
let cars=[]
function isoX(c,r){return(c-r)*(TW/2)}
function isoY(c,r){return(c+r)*(TH/2)}
function screenToGrid(sx,sy){
const rx=sx-mapOx,ry=sy-mapOy
const c=Math.floor((rx/(TW/2)+ry/(TH/2))/2)
const r=Math.floor((ry/(TH/2)-rx/(TW/2))/2)
return{r,c}
}
function resize(){
const rect=$cv.parentElement.getBoundingClientRect(),dpr=devicePixelRatio||1
$cv.width=rect.width*dpr;$cv.height=rect.height*dpr
ctx.setTransform(dpr,0,0,dpr,0,0)
W=rect.width;H=rect.height
const n=GW+GH
const fitW=(W*0.9)/(n/2),fitH=(H*0.85)/(n/2)
TW=Math.min(fitW,fitH*2);TH=TW/2
mapOx=(W-(GW+GH)*(TW/2)/2)/2+GH*(TW/2)/2+TW/4
mapOy=(H-(GW+GH)*(TH/2)/2)/2+TH
}
function updateCars(){
cars=cars.filter(v=>v.t<1)
if(cars.length<6&&Math.random()<0.03){
const tiles=[]
for(let r=0;r<GH;r++)for(let c=0;c<GW;c++)if(TILES[grid[r][c]]?.conn.some(v=>v))tiles.push({r,c})
if(tiles.length){
const spot=tiles[Math.floor(Math.random()*tiles.length)]
const conn=TILES[grid[spot.r][spot.c]].conn
const dirs=[];conn.forEach((v,i)=>{if(v)dirs.push(i)})
if(dirs.length){
const d=dirs[Math.floor(Math.random()*dirs.length)]
const emojis=['🚗','🚙','🚕','🚌']
cars.push({r:spot.r,c:spot.c,dir:d,t:0,e:emojis[Math.floor(Math.random()*emojis.length)]})
}
}
}
for(const v of cars)v.t+=0.006
}
function draw(){
animT+=0.01
updateCars()
// Sky/grass background
ctx.fillStyle='#92aa6a';ctx.fillRect(0,0,W,H)
// Draw tiles back-to-front
for(let r=0;r<GH;r++)for(let c=0;c<GW;c++){
const type=grid[r][c]
const sx=mapOx+isoX(c,r),sy=mapOy+isoY(c,r)
const isGhost=r===ghostR&&c===ghostC&&type==='empty'&&selType
drawIsoTile(sx,sy,type,1)
if(isGhost){
ctx.globalAlpha=0.4
drawIsoTile(sx,sy,selType,0.4)
ctx.globalAlpha=1
}
}
// Draw cars
const cfs=Math.max(6,TW*0.12)
ctx.font=`${cfs}px serif`;ctx.textAlign='center';ctx.textBaseline='middle'
for(const v of cars){
const sx=mapOx+isoX(v.c,v.r),sy=mapOy+isoY(v.c,v.r)
// Move along road direction in iso space
const off=v.t*TW*0.4
let dx=0,dy=0
if(v.dir===0){dx=off*0.5;dy=-off*0.25} // N
if(v.dir===1){dx=off*0.5;dy=off*0.25} // E
if(v.dir===2){dx=-off*0.5;dy=off*0.25} // S
if(v.dir===3){dx=-off*0.5;dy=-off*0.25}// W
// Slight offset to right side of road
const rOff=TW*0.04
if(v.dir===0||v.dir===2){dx+=rOff}else{dy+=rOff}
ctx.fillText(v.e,sx+dx,sy+dy)
}
requestAnimationFrame(draw)
}
function drawIsoTile(sx,sy,type,alpha){
const def=TILES[type]
if(!def)return
const hw=TW/2,hh=TH/2
const conn=def.conn
// === DIAMOND BASE (green grass) ===
ctx.fillStyle='#7da84e'
ctx.beginPath()
ctx.moveTo(sx,sy-hh) // top
ctx.lineTo(sx+hw,sy) // right
ctx.lineTo(sx,sy+hh) // bottom
ctx.lineTo(sx-hw,sy) // left
ctx.closePath()
ctx.fill()
// Subtle edge
ctx.strokeStyle='rgba(0,0,0,0.06)';ctx.lineWidth=0.5;ctx.stroke()
// === ROADS as iso parallelograms ===
const rw=TW*RW // road width in pixels
const rhw=rw/2
ctx.fillStyle='#3a3a3a'
// N: from center toward top-left edge
if(conn[0]) drawIsoRoad(sx,sy,hw,hh,rhw,0)
// E: from center toward top-right edge
if(conn[1]) drawIsoRoad(sx,sy,hw,hh,rhw,1)
// S: from center toward bottom-right edge
if(conn[2]) drawIsoRoad(sx,sy,hw,hh,rhw,2)
// W: from center toward bottom-left edge
if(conn[3]) drawIsoRoad(sx,sy,hw,hh,rhw,3)
// Center junction
if(conn.filter(v=>v).length>=2){
ctx.fillStyle='#3a3a3a'
const jr=rhw*0.7
ctx.beginPath();ctx.arc(sx,sy,jr,0,Math.PI*2);ctx.fill()
}
// Road markings (center dashed line + edge lines)
for(let d=0;d<4;d++){
if(!conn[d])continue
drawIsoRoadMarkings(sx,sy,hw,hh,rhw,d)
}
// === BUILDING CONTENT ===
if(def.content.length>0){
const fs=Math.max(8,TW*0.18)
ctx.font=`${fs}px serif`;ctx.textAlign='center';ctx.textBaseline='middle'
for(const item of def.content){
// Convert content position to iso coordinates
const scale=item.s||1
ctx.font=`${fs*scale}px serif`
// ox,oy are in tile-local coords (-0.5..0.5)
// Convert to iso: ox goes along E direction, oy goes along S direction
const px=sx + item.ox*TW*0.45 + item.oy*TW*0.05
const py=sy + item.ox*TH*0.15 + item.oy*TH*0.4
ctx.fillText(item.e,px,py)
}
ctx.textBaseline='alphabetic'
}
}
function drawIsoRoad(sx,sy,hw,hh,rhw,dir){
// Draw road segment from center to edge in direction dir
// In iso: N=top-left, E=top-right, S=bottom-right, W=bottom-left
const steps=6
ctx.beginPath()
for(let i=0;i<=steps;i++){
const t=i/steps*0.5 // 0..0.5 (center to edge)
let px,py
if(dir===0){px=sx-t*hw;py=sy-t*hh} // N (toward top-left)
if(dir===1){px=sx+t*hw;py=sy-t*hh} // E (toward top-right)
if(dir===2){px=sx+t*hw;py=sy+t*hh} // S (toward bottom-right)
if(dir===3){px=sx-t*hw;py=sy+t*hh} // W (toward bottom-left)
// Perpendicular offset for road width
let nx,ny // normal direction
if(dir===0||dir===2){nx=1/(hw/hh);ny=1} else{nx=-1/(hw/hh);ny=1}
const len=Math.sqrt(nx*nx+ny*ny);nx/=len;ny/=len
if(i===0){
ctx.moveTo(px+nx*rhw,py+ny*rhw*0.5)
}
ctx.lineTo(px+nx*rhw,py+ny*rhw*0.5)
}
// Go back on the other side
for(let i=steps;i>=0;i--){
const t=i/steps*0.5
let px,py
if(dir===0){px=sx-t*hw;py=sy-t*hh}
if(dir===1){px=sx+t*hw;py=sy-t*hh}
if(dir===2){px=sx+t*hw;py=sy+t*hh}
if(dir===3){px=sx-t*hw;py=sy+t*hh}
let nx,ny
if(dir===0||dir===2){nx=1/(hw/hh);ny=1}else{nx=-1/(hw/hh);ny=1}
const len=Math.sqrt(nx*nx+ny*ny);nx/=len;ny/=len
ctx.lineTo(px-nx*rhw,py-ny*rhw*0.5)
}
ctx.closePath()
ctx.fill()
}
function drawIsoRoadMarkings(sx,sy,hw,hh,rhw,dir){
// Center dashed line
ctx.strokeStyle='rgba(255,255,200,0.5)';ctx.lineWidth=Math.max(1.2,TW*0.015)
ctx.setLineDash([TW*0.06,TW*0.04])
ctx.beginPath()
let ex,ey
if(dir===0){ex=sx-hw*0.48;ey=sy-hh*0.48}
if(dir===1){ex=sx+hw*0.48;ey=sy-hh*0.48}
if(dir===2){ex=sx+hw*0.48;ey=sy+hh*0.48}
if(dir===3){ex=sx-hw*0.48;ey=sy+hh*0.48}
ctx.moveTo(sx,sy);ctx.lineTo(ex,ey);ctx.stroke()
ctx.setLineDash([])
// Edge lines (solid, thicker)
ctx.strokeStyle='rgba(255,255,255,0.3)';ctx.lineWidth=Math.max(0.8,TW*0.01)
let nx,ny
if(dir===0||dir===2){nx=1/(hw/hh);ny=1}else{nx=-1/(hw/hh);ny=1}
const len=Math.sqrt(nx*nx+ny*ny);nx/=len;ny/=len
for(const side of [1,-1]){
ctx.beginPath()
ctx.moveTo(sx+nx*rhw*side,sy+ny*rhw*0.5*side)
ctx.lineTo(ex+nx*rhw*side,ey+ny*rhw*0.5*side)
ctx.stroke()
}
}
// === UI ===
function renderAll(){
document.getElementById('h-bud').textContent=budget
document.getElementById('h-rnd').textContent=`${round}/${level.rounds}`
if(selType&&budget<TILES[selType].cost)selType=null
const ps=[{n:'Wirtschaft',i:'💰',v:scores.eco},{n:'Zufriedenheit',i:'😊',v:scores.sat},{n:'Umwelt',i:'🌿',v:scores.env},{n:'Verkehr',i:'🚗',v:scores.traf,inv:1},{n:'Versorgung',i:'🛒',v:scores.sup},{n:'Erreichbarkeit',i:'🔗',v:scores.reach}]
document.getElementById('params').innerHTML=ps.map(p=>{const g=p.inv?p.v<40:p.v>55,b=p.inv?p.v>65:p.v<30,col=b?'var(--coral)':g?'var(--moss)':'var(--sand)';return`<div class="param"><div class="param-i">${p.i}</div><div class="param-inf"><div class="param-n">${p.n}</div><div class="param-bar"><div class="param-bar-f" style="width:${p.v}%;background:${col}"></div></div></div><div class="param-v" style="color:${col}">${p.v}</div></div>`}).join('')
document.getElementById('tiles').innerHTML=level.unlocked.map(id=>{const d=TILES[id],af=budget>=d.cost;return`<div class="tb ${selType===id?'on':''} ${!af?'off':''}" data-t="${id}"><span class="te">${d.content[0]?.e||'🛣️'}</span><span class="tn">${d.n}</span><span class="tc">💰${d.cost}</span></div>`}).join('')
document.querySelectorAll('.tb').forEach(b=>b.addEventListener('click',()=>{selType=selType===b.dataset.t?null:b.dataset.t;renderAll()}))
document.getElementById('msgs').innerHTML=messages.slice(0,7).map(m=>`<div class="msg ${m.type}">${m.text}</div>`).join('')
drawGraph()
}
function drawGraph(){const gc=document.getElementById('graph');if(!gc||history.length<1)return;const gx=gc.getContext('2d'),rect=gc.parentElement.getBoundingClientRect(),dpr=devicePixelRatio||1;gc.width=rect.width*dpr;gc.height=65*dpr;gx.setTransform(dpr,0,0,dpr,0,0);const gW=rect.width,gH=65;gx.clearRect(0,0,gW,gH);const lines=[{k:'eco',c:'#c4a35a'},{k:'sat',c:'#5a8a5e'},{k:'env',c:'#4a8a4a'},{k:'sup',c:'#4a7c8a'},{k:'reach',c:'#8a6a8a'}];for(const l of lines){gx.strokeStyle=l.c;gx.lineWidth=1;gx.beginPath();history.forEach((h,i)=>{const x=2+(i/Math.max(1,level.rounds-1))*(gW-4),y=2+(gH-4)-(h[l.k]/100)*(gH-4);i===0?gx.moveTo(x,y):gx.lineTo(x,y)});gx.stroke()}}
// === INPUT ===
$cv.addEventListener('mousemove',e=>{const r=$cv.getBoundingClientRect();const g=screenToGrid(e.clientX-r.left,e.clientY-r.top);ghostR=g.r;ghostC=g.c;if(ghostR<0||ghostR>=GH||ghostC<0||ghostC>=GW){ghostR=-1;ghostC=-1}})
$cv.addEventListener('click',e=>{
if(gameOver||!selType)return;const r=$cv.getBoundingClientRect()
const g=screenToGrid(e.clientX-r.left,e.clientY-r.top)
if(g.r>=0&&g.r<GH&&g.c>=0&&g.c<GW)place(g.r,g.c)
})
$cv.addEventListener('mouseleave',()=>{ghostR=-1;ghostC=-1})
document.addEventListener('keydown',e=>{if(selType){selType=null;renderAll()}})
document.getElementById('btn-next').addEventListener('click',nextRound)
document.getElementById('btn-go').addEventListener('click',()=>{document.getElementById('ov-intro').classList.add('hidden');renderAll()})
document.getElementById('btn-again').addEventListener('click',()=>{document.getElementById('ov-end').classList.add('hidden');initGame(LEVELS[0]);renderAll()})
document.getElementById('btn-reset').addEventListener('click',()=>{if(confirm('Neu?')){initGame(LEVELS[0]);renderAll()}})
initGame(LEVELS[0]);resize();window.addEventListener('resize',resize);renderAll();draw()
</script>
</body>
</html>