Add zoom controls, walk animation, and zone background colors
Browse files- City fits on one screen by default (WORLD_W/H = 1.0, zoom via ctx.scale)
- Scroll wheel zoom centered on cursor; +/- buttons; Fit button
- Rectangle zoom: draw a selection box to zoom into any area
- Walking agents now animate arms (+/-18px) and legs (+/-13px) only when moving
- 11 colored zone overlays (park green, commercial terracotta, office blue, etc.)
- Richer ground gradient with 3-stop depth variation
- Zoom indicator in canvas corner; pan corrected for zoom level
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- web/index.html +224 -38
web/index.html
CHANGED
|
@@ -164,6 +164,11 @@
|
|
| 164 |
<button class="ctrl-btn" id="btn-10x" onclick="setSpeed(0.1)" title="10x speed">10x</button>
|
| 165 |
<button class="ctrl-btn" id="btn-50x" onclick="setSpeed(0.02)" title="50x speed">50x</button>
|
| 166 |
<span class="speed-label" id="speed-label">1x</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
</span>
|
| 168 |
<span id="api-calls">API: 0</span>
|
| 169 |
<span id="cost">$0.00</span>
|
|
@@ -209,9 +214,11 @@ const POLL_INTERVAL = 2000;
|
|
| 209 |
const HORIZON = 0.14;
|
| 210 |
|
| 211 |
// --- CITY LAYOUT ---
|
| 212 |
-
// World dimensions
|
| 213 |
-
const WORLD_W = 1.
|
| 214 |
-
const WORLD_H = 1.
|
|
|
|
|
|
|
| 215 |
|
| 216 |
// Road network
|
| 217 |
const ROADS = [
|
|
@@ -336,9 +343,13 @@ let stars = [];
|
|
| 336 |
let activeTab = 'agents';
|
| 337 |
let agentIdxMap = {};
|
| 338 |
|
| 339 |
-
// Pan state
|
| 340 |
let panX = 0, panY = 0;
|
|
|
|
| 341 |
let isDragging = false, dragStartX = 0, dragStartY = 0, dragPanStartX = 0, dragPanStartY = 0;
|
|
|
|
|
|
|
|
|
|
| 342 |
|
| 343 |
// Tree / decorations cache
|
| 344 |
let trees = [];
|
|
@@ -401,6 +412,7 @@ function initCanvas() {
|
|
| 401 |
canvas.addEventListener('mousemove', onCanvasDrag);
|
| 402 |
canvas.addEventListener('mouseup', onCanvasDragEnd);
|
| 403 |
canvas.addEventListener('mouseleave', onCanvasDragEnd);
|
|
|
|
| 404 |
initParticles();
|
| 405 |
requestAnimationFrame(animate);
|
| 406 |
}
|
|
@@ -410,11 +422,11 @@ function resizeCanvas() {
|
|
| 410 |
canvas.height = c.clientHeight;
|
| 411 |
}
|
| 412 |
|
| 413 |
-
// World size
|
| 414 |
-
function worldW() { return canvas.width
|
| 415 |
-
function worldH() { return canvas.height
|
| 416 |
-
function maxPanX() { return Math.max(0,
|
| 417 |
-
function maxPanY() { return Math.max(0,
|
| 418 |
|
| 419 |
function onPanSlider() {
|
| 420 |
const sx = document.getElementById('pan-x');
|
|
@@ -430,19 +442,89 @@ function syncSliders() {
|
|
| 430 |
}
|
| 431 |
function onCanvasDragStart(e) {
|
| 432 |
if (e.button !== 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
isDragging = true;
|
| 434 |
dragStartX = e.clientX; dragStartY = e.clientY;
|
| 435 |
dragPanStartX = panX; dragPanStartY = panY;
|
| 436 |
}
|
| 437 |
function onCanvasDrag(e) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
if (!isDragging) return;
|
| 439 |
const dx = dragStartX - e.clientX, dy = dragStartY - e.clientY;
|
| 440 |
if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return;
|
| 441 |
-
panX = Math.max(0, Math.min(maxPanX(), dragPanStartX + dx));
|
| 442 |
-
panY = Math.max(0, Math.min(maxPanY(), dragPanStartY + dy));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
syncSliders();
|
| 444 |
}
|
| 445 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
|
| 447 |
function animate() {
|
| 448 |
animFrame++;
|
|
@@ -464,13 +546,20 @@ function draw() {
|
|
| 464 |
const W = worldW(), H = worldH();
|
| 465 |
const cW = canvas.width, cH = canvas.height;
|
| 466 |
|
| 467 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
drawSky(cW, cH);
|
| 469 |
|
| 470 |
ctx.save();
|
|
|
|
| 471 |
ctx.translate(-panX, -panY);
|
| 472 |
|
| 473 |
drawGround(W, H);
|
|
|
|
| 474 |
drawWeather(W, H);
|
| 475 |
drawRoads(W, H);
|
| 476 |
drawSidewalks(W, H);
|
|
@@ -508,6 +597,29 @@ function draw() {
|
|
| 508 |
}
|
| 509 |
|
| 510 |
ctx.restore();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
}
|
| 512 |
|
| 513 |
// Auto-compute positions for generated houses not in LOCATION_POSITIONS
|
|
@@ -658,32 +770,50 @@ function drawMoon(x, y, r) {
|
|
| 658 |
function drawGround(W, H) {
|
| 659 |
const hLine = (canvas.height * HORIZON);
|
| 660 |
const gt = GROUND_TINT[currentTimeOfDay] || GROUND_TINT.morning;
|
| 661 |
-
const grad = ctx.createLinearGradient(0, hLine, 0, H);
|
| 662 |
const bc = hexToRgb(gt.base);
|
| 663 |
-
|
| 664 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
ctx.fillStyle = grad;
|
| 666 |
ctx.fillRect(0, hLine, W, H - hLine);
|
| 667 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 668 |
if (currentWeather === 'rainy' || currentWeather === 'stormy') {
|
| 669 |
ctx.fillStyle = `rgba(40, 50, 70, ${currentWeather === 'stormy' ? 0.35 : 0.2})`;
|
| 670 |
ctx.fillRect(0, hLine, W, H - hLine);
|
| 671 |
}
|
| 672 |
|
| 673 |
-
//
|
| 674 |
-
ctx.fillStyle = `rgba(${60+bc.r*0.3},${90+bc.g*0.3},${40+bc.b*0.2}, 0.
|
| 675 |
-
for (let i = 0; i <
|
| 676 |
-
const gx = (i*37+13)%W;
|
| 677 |
-
const gy = hLine + 10 + ((i*53+7)%(H-hLine-15));
|
| 678 |
ctx.fillRect(gx, gy, 2, 3);
|
| 679 |
}
|
| 680 |
|
| 681 |
-
|
|
|
|
| 682 |
const s = SKY[currentTimeOfDay] || SKY.morning;
|
| 683 |
horizGrad.addColorStop(0, s.bot);
|
| 684 |
horizGrad.addColorStop(1, gt.base);
|
| 685 |
ctx.fillStyle = horizGrad;
|
| 686 |
-
ctx.fillRect(0, hLine-4, W,
|
| 687 |
}
|
| 688 |
|
| 689 |
function hexToRgb(hex) {
|
|
@@ -691,6 +821,51 @@ function hexToRgb(hex) {
|
|
| 691 |
return {r,g,b};
|
| 692 |
}
|
| 693 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 694 |
// ============================================================
|
| 695 |
// WEATHER
|
| 696 |
// ============================================================
|
|
@@ -1489,9 +1664,11 @@ function drawPerson(id, agent, globalIdx, W, H) {
|
|
| 1489 |
|
| 1490 |
if (isSel) { ctx.shadowColor=color; ctx.shadowBlur=12; }
|
| 1491 |
|
| 1492 |
-
const
|
| 1493 |
-
const
|
| 1494 |
-
const
|
|
|
|
|
|
|
| 1495 |
|
| 1496 |
// Ground shadow (isometric ellipse)
|
| 1497 |
ctx.fillStyle = 'rgba(0,0,0,0.18)';
|
|
@@ -1529,20 +1706,28 @@ function drawPerson(id, agent, globalIdx, W, H) {
|
|
| 1529 |
}
|
| 1530 |
|
| 1531 |
// Legs (with perspective offset)
|
| 1532 |
-
|
| 1533 |
-
ctx.
|
| 1534 |
-
ctx.
|
| 1535 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1536 |
|
| 1537 |
// Feet
|
| 1538 |
ctx.fillStyle = '#2a2a2a';
|
| 1539 |
-
ctx.fillRect(-
|
| 1540 |
-
ctx.fillRect(1
|
| 1541 |
|
| 1542 |
// Arms
|
| 1543 |
-
ctx.strokeStyle = skin;
|
| 1544 |
-
ctx.
|
| 1545 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1546 |
|
| 1547 |
// Head (slightly offset for 2.5D)
|
| 1548 |
ctx.fillStyle = skin;
|
|
@@ -1616,8 +1801,9 @@ function drawPerson(id, agent, globalIdx, W, H) {
|
|
| 1616 |
// ============================================================
|
| 1617 |
function onCanvasClick(e) {
|
| 1618 |
if (isDragging) return;
|
|
|
|
| 1619 |
const rect=canvas.getBoundingClientRect();
|
| 1620 |
-
const mx=e.clientX-rect.left+panX, my=e.clientY-rect.top+panY;
|
| 1621 |
let clicked=null, minD=24;
|
| 1622 |
for (const [id,pos] of Object.entries(agentPositions)) {
|
| 1623 |
const d=Math.hypot(mx-pos.x,my-pos.y);
|
|
@@ -1635,11 +1821,11 @@ function onCanvasClick(e) {
|
|
| 1635 |
|
| 1636 |
function onCanvasMouseMove(e) {
|
| 1637 |
const rect=canvas.getBoundingClientRect();
|
| 1638 |
-
const mx=e.clientX-rect.left+panX, my=e.clientY-rect.top+panY;
|
| 1639 |
const W=worldW(), H=worldH();
|
| 1640 |
const tt=document.getElementById('tooltip');
|
| 1641 |
|
| 1642 |
-
if (isDragging) return;
|
| 1643 |
|
| 1644 |
let foundAgent=null;
|
| 1645 |
for (const [id,pos] of Object.entries(agentPositions)) {
|
|
|
|
| 164 |
<button class="ctrl-btn" id="btn-10x" onclick="setSpeed(0.1)" title="10x speed">10x</button>
|
| 165 |
<button class="ctrl-btn" id="btn-50x" onclick="setSpeed(0.02)" title="50x speed">50x</button>
|
| 166 |
<span class="speed-label" id="speed-label">1x</span>
|
| 167 |
+
<span style="color:#1a3a6e;margin:0 2px">β</span>
|
| 168 |
+
<button class="ctrl-btn" id="btn-rect-zoom" onclick="toggleRectZoom()" title="Draw a rectangle to zoom into that area (Shift+drag)">β¬</button>
|
| 169 |
+
<button class="ctrl-btn" onclick="zoomBy(1.3)" title="Zoom In (scroll up)">οΌ</button>
|
| 170 |
+
<button class="ctrl-btn" onclick="zoomBy(1/1.3)" title="Zoom Out (scroll down)">οΌ</button>
|
| 171 |
+
<button class="ctrl-btn" onclick="zoomFit()" title="Fit entire city on screen">Fit</button>
|
| 172 |
</span>
|
| 173 |
<span id="api-calls">API: 0</span>
|
| 174 |
<span id="cost">$0.00</span>
|
|
|
|
| 214 |
const HORIZON = 0.14;
|
| 215 |
|
| 216 |
// --- CITY LAYOUT ---
|
| 217 |
+
// World dimensions β 1:1 with canvas (zoom handled separately)
|
| 218 |
+
const WORLD_W = 1.0;
|
| 219 |
+
const WORLD_H = 1.0;
|
| 220 |
+
const MIN_ZOOM = 0.5;
|
| 221 |
+
const MAX_ZOOM = 10.0;
|
| 222 |
|
| 223 |
// Road network
|
| 224 |
const ROADS = [
|
|
|
|
| 343 |
let activeTab = 'agents';
|
| 344 |
let agentIdxMap = {};
|
| 345 |
|
| 346 |
+
// Pan & zoom state
|
| 347 |
let panX = 0, panY = 0;
|
| 348 |
+
let zoom = 1.0;
|
| 349 |
let isDragging = false, dragStartX = 0, dragStartY = 0, dragPanStartX = 0, dragPanStartY = 0;
|
| 350 |
+
// Rectangle-zoom state
|
| 351 |
+
let rectZoomMode = false, isRectDragging = false, rectStart = null, rectEnd = null;
|
| 352 |
+
let _blockNextClick = false;
|
| 353 |
|
| 354 |
// Tree / decorations cache
|
| 355 |
let trees = [];
|
|
|
|
| 412 |
canvas.addEventListener('mousemove', onCanvasDrag);
|
| 413 |
canvas.addEventListener('mouseup', onCanvasDragEnd);
|
| 414 |
canvas.addEventListener('mouseleave', onCanvasDragEnd);
|
| 415 |
+
canvas.addEventListener('wheel', onCanvasWheel, {passive: false});
|
| 416 |
initParticles();
|
| 417 |
requestAnimationFrame(animate);
|
| 418 |
}
|
|
|
|
| 422 |
canvas.height = c.clientHeight;
|
| 423 |
}
|
| 424 |
|
| 425 |
+
// World size β base is canvas size; zoom applied via ctx.scale
|
| 426 |
+
function worldW() { return canvas.width; }
|
| 427 |
+
function worldH() { return canvas.height; }
|
| 428 |
+
function maxPanX() { return Math.max(0, canvas.width * (1 - 1 / zoom)); }
|
| 429 |
+
function maxPanY() { return Math.max(0, canvas.height * (1 - 1 / zoom)); }
|
| 430 |
|
| 431 |
function onPanSlider() {
|
| 432 |
const sx = document.getElementById('pan-x');
|
|
|
|
| 442 |
}
|
| 443 |
function onCanvasDragStart(e) {
|
| 444 |
if (e.button !== 0) return;
|
| 445 |
+
if (rectZoomMode) {
|
| 446 |
+
const r = canvas.getBoundingClientRect();
|
| 447 |
+
rectStart = {x: e.clientX - r.left, y: e.clientY - r.top};
|
| 448 |
+
rectEnd = {...rectStart};
|
| 449 |
+
isRectDragging = true;
|
| 450 |
+
return;
|
| 451 |
+
}
|
| 452 |
isDragging = true;
|
| 453 |
dragStartX = e.clientX; dragStartY = e.clientY;
|
| 454 |
dragPanStartX = panX; dragPanStartY = panY;
|
| 455 |
}
|
| 456 |
function onCanvasDrag(e) {
|
| 457 |
+
if (isRectDragging) {
|
| 458 |
+
const r = canvas.getBoundingClientRect();
|
| 459 |
+
rectEnd = {x: e.clientX - r.left, y: e.clientY - r.top};
|
| 460 |
+
return;
|
| 461 |
+
}
|
| 462 |
if (!isDragging) return;
|
| 463 |
const dx = dragStartX - e.clientX, dy = dragStartY - e.clientY;
|
| 464 |
if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return;
|
| 465 |
+
panX = Math.max(0, Math.min(maxPanX(), dragPanStartX + dx / zoom));
|
| 466 |
+
panY = Math.max(0, Math.min(maxPanY(), dragPanStartY + dy / zoom));
|
| 467 |
+
syncSliders();
|
| 468 |
+
}
|
| 469 |
+
function onCanvasDragEnd() {
|
| 470 |
+
if (isRectDragging) {
|
| 471 |
+
isRectDragging = false;
|
| 472 |
+
if (rectStart && rectEnd) {
|
| 473 |
+
const rw = Math.abs(rectEnd.x - rectStart.x), rh = Math.abs(rectEnd.y - rectStart.y);
|
| 474 |
+
if (rw > 10 && rh > 10) { applyRectZoom(); _blockNextClick = true; }
|
| 475 |
+
}
|
| 476 |
+
rectStart = null; rectEnd = null;
|
| 477 |
+
rectZoomMode = false;
|
| 478 |
+
const btn = document.getElementById('btn-rect-zoom');
|
| 479 |
+
if (btn) btn.classList.remove('active');
|
| 480 |
+
canvas.style.cursor = 'default';
|
| 481 |
+
return;
|
| 482 |
+
}
|
| 483 |
+
isDragging = false;
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
// ============================================================
|
| 487 |
+
// ZOOM FUNCTIONS
|
| 488 |
+
// ============================================================
|
| 489 |
+
function onCanvasWheel(e) {
|
| 490 |
+
e.preventDefault();
|
| 491 |
+
const r = canvas.getBoundingClientRect();
|
| 492 |
+
const sx = e.clientX - r.left, sy = e.clientY - r.top;
|
| 493 |
+
zoomAround(e.deltaY < 0 ? 1.15 : 1 / 1.15, sx, sy);
|
| 494 |
+
}
|
| 495 |
+
function zoomAround(factor, sx, sy) {
|
| 496 |
+
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom * factor));
|
| 497 |
+
if (newZoom === zoom) return;
|
| 498 |
+
// World point under screen pixel (sx, sy): wx = sx/zoom + panX
|
| 499 |
+
const wx = sx / zoom + panX, wy = sy / zoom + panY;
|
| 500 |
+
zoom = newZoom;
|
| 501 |
+
panX = Math.max(0, Math.min(maxPanX(), wx - sx / zoom));
|
| 502 |
+
panY = Math.max(0, Math.min(maxPanY(), wy - sy / zoom));
|
| 503 |
syncSliders();
|
| 504 |
}
|
| 505 |
+
function zoomBy(factor) { zoomAround(factor, canvas.width / 2, canvas.height / 2); }
|
| 506 |
+
function zoomFit() { zoom = 1.0; panX = 0; panY = 0; syncSliders(); }
|
| 507 |
+
function applyRectZoom() {
|
| 508 |
+
if (!rectStart || !rectEnd) return;
|
| 509 |
+
const x1 = Math.min(rectStart.x, rectEnd.x), y1 = Math.min(rectStart.y, rectEnd.y);
|
| 510 |
+
const x2 = Math.max(rectStart.x, rectEnd.x), y2 = Math.max(rectStart.y, rectEnd.y);
|
| 511 |
+
const rw = x2 - x1, rh = y2 - y1;
|
| 512 |
+
if (rw < 10 || rh < 10) return;
|
| 513 |
+
// Convert screen rect corners to world coords
|
| 514 |
+
const wx1 = x1 / zoom + panX, wy1 = y1 / zoom + panY;
|
| 515 |
+
const wx2 = x2 / zoom + panX, wy2 = y2 / zoom + panY;
|
| 516 |
+
const newZoom = Math.min(MAX_ZOOM, Math.min(canvas.width / (wx2 - wx1), canvas.height / (wy2 - wy1)));
|
| 517 |
+
zoom = Math.max(MIN_ZOOM, newZoom);
|
| 518 |
+
panX = Math.max(0, Math.min(maxPanX(), wx1));
|
| 519 |
+
panY = Math.max(0, Math.min(maxPanY(), wy1));
|
| 520 |
+
syncSliders();
|
| 521 |
+
}
|
| 522 |
+
function toggleRectZoom() {
|
| 523 |
+
rectZoomMode = !rectZoomMode;
|
| 524 |
+
const btn = document.getElementById('btn-rect-zoom');
|
| 525 |
+
if (btn) btn.classList.toggle('active', rectZoomMode);
|
| 526 |
+
canvas.style.cursor = rectZoomMode ? 'crosshair' : 'default';
|
| 527 |
+
}
|
| 528 |
|
| 529 |
function animate() {
|
| 530 |
animFrame++;
|
|
|
|
| 546 |
const W = worldW(), H = worldH();
|
| 547 |
const cW = canvas.width, cH = canvas.height;
|
| 548 |
|
| 549 |
+
// Background fill (covers any gap between sky and zoomed ground)
|
| 550 |
+
const skyCfg = SKY[currentTimeOfDay] || SKY.morning;
|
| 551 |
+
ctx.fillStyle = skyCfg.bot;
|
| 552 |
+
ctx.fillRect(0, 0, cW, cH);
|
| 553 |
+
|
| 554 |
+
// Sky drawn at canvas size (no zoom transform β always fills background)
|
| 555 |
drawSky(cW, cH);
|
| 556 |
|
| 557 |
ctx.save();
|
| 558 |
+
ctx.scale(zoom, zoom);
|
| 559 |
ctx.translate(-panX, -panY);
|
| 560 |
|
| 561 |
drawGround(W, H);
|
| 562 |
+
drawZones(W, H);
|
| 563 |
drawWeather(W, H);
|
| 564 |
drawRoads(W, H);
|
| 565 |
drawSidewalks(W, H);
|
|
|
|
| 597 |
}
|
| 598 |
|
| 599 |
ctx.restore();
|
| 600 |
+
|
| 601 |
+
// Rect-zoom selection overlay (screen coords, after restore)
|
| 602 |
+
if (isRectDragging && rectStart && rectEnd) {
|
| 603 |
+
const x1 = Math.min(rectStart.x, rectEnd.x), y1 = Math.min(rectStart.y, rectEnd.y);
|
| 604 |
+
const x2 = Math.max(rectStart.x, rectEnd.x), y2 = Math.max(rectStart.y, rectEnd.y);
|
| 605 |
+
ctx.fillStyle = 'rgba(78,204,163,0.08)';
|
| 606 |
+
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
|
| 607 |
+
ctx.strokeStyle = 'rgba(78,204,163,0.9)';
|
| 608 |
+
ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]);
|
| 609 |
+
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
| 610 |
+
ctx.setLineDash([]);
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
// Zoom level indicator
|
| 614 |
+
if (zoom !== 1.0 || rectZoomMode) {
|
| 615 |
+
ctx.font = 'bold 10px Segoe UI'; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
|
| 616 |
+
const label = rectZoomMode ? 'β¬ draw rect to zoom' : `${zoom.toFixed(1)}x`;
|
| 617 |
+
const tw = ctx.measureText(label).width + 14;
|
| 618 |
+
ctx.fillStyle = 'rgba(15,52,96,0.75)';
|
| 619 |
+
ctx.fillRect(cW - tw - 4, cH - 22, tw + 4, 18);
|
| 620 |
+
ctx.fillStyle = rectZoomMode ? '#f0c040' : '#4ecca3';
|
| 621 |
+
ctx.fillText(label, cW - 6, cH - 6);
|
| 622 |
+
}
|
| 623 |
}
|
| 624 |
|
| 625 |
// Auto-compute positions for generated houses not in LOCATION_POSITIONS
|
|
|
|
| 770 |
function drawGround(W, H) {
|
| 771 |
const hLine = (canvas.height * HORIZON);
|
| 772 |
const gt = GROUND_TINT[currentTimeOfDay] || GROUND_TINT.morning;
|
|
|
|
| 773 |
const bc = hexToRgb(gt.base);
|
| 774 |
+
const isDark = currentTimeOfDay === 'night' || currentTimeOfDay === 'evening';
|
| 775 |
+
const isDawn = currentTimeOfDay === 'dawn';
|
| 776 |
+
|
| 777 |
+
// Base gradient β three-band ground for depth
|
| 778 |
+
const grad = ctx.createLinearGradient(0, hLine, 0, H);
|
| 779 |
+
grad.addColorStop(0, `rgb(${bc.r+28},${bc.g+40},${bc.b+12})`);
|
| 780 |
+
grad.addColorStop(0.4, `rgb(${bc.r+10},${bc.g+20},${bc.b+5})`);
|
| 781 |
+
grad.addColorStop(1, `rgb(${Math.max(0,bc.r-20)},${Math.max(0,bc.g-20)},${Math.max(0,bc.b-14)})`);
|
| 782 |
ctx.fillStyle = grad;
|
| 783 |
ctx.fillRect(0, hLine, W, H - hLine);
|
| 784 |
|
| 785 |
+
// Horizontal band of slightly different shade β mid-ground variation
|
| 786 |
+
if (!isDark) {
|
| 787 |
+
const bandY = hLine + (H - hLine) * 0.5;
|
| 788 |
+
const bandGrad = ctx.createLinearGradient(0, bandY - 12, 0, bandY + 12);
|
| 789 |
+
bandGrad.addColorStop(0, 'rgba(0,0,0,0)');
|
| 790 |
+
bandGrad.addColorStop(0.5, isDawn ? 'rgba(180,120,60,0.06)' : 'rgba(80,130,40,0.06)');
|
| 791 |
+
bandGrad.addColorStop(1, 'rgba(0,0,0,0)');
|
| 792 |
+
ctx.fillStyle = bandGrad;
|
| 793 |
+
ctx.fillRect(0, bandY - 12, W, 24);
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
// Rain/storm overlay
|
| 797 |
if (currentWeather === 'rainy' || currentWeather === 'stormy') {
|
| 798 |
ctx.fillStyle = `rgba(40, 50, 70, ${currentWeather === 'stormy' ? 0.35 : 0.2})`;
|
| 799 |
ctx.fillRect(0, hLine, W, H - hLine);
|
| 800 |
}
|
| 801 |
|
| 802 |
+
// Scattered grass tufts for texture
|
| 803 |
+
ctx.fillStyle = `rgba(${60+bc.r*0.3},${90+bc.g*0.3},${40+bc.b*0.2}, 0.14)`;
|
| 804 |
+
for (let i = 0; i < 120; i++) {
|
| 805 |
+
const gx = (i * 37 + 13) % W;
|
| 806 |
+
const gy = hLine + 10 + ((i * 53 + 7) % (H - hLine - 15));
|
| 807 |
ctx.fillRect(gx, gy, 2, 3);
|
| 808 |
}
|
| 809 |
|
| 810 |
+
// Horizon blend
|
| 811 |
+
const horizGrad = ctx.createLinearGradient(0, hLine - 4, 0, hLine + 8);
|
| 812 |
const s = SKY[currentTimeOfDay] || SKY.morning;
|
| 813 |
horizGrad.addColorStop(0, s.bot);
|
| 814 |
horizGrad.addColorStop(1, gt.base);
|
| 815 |
ctx.fillStyle = horizGrad;
|
| 816 |
+
ctx.fillRect(0, hLine - 4, W, 12);
|
| 817 |
}
|
| 818 |
|
| 819 |
function hexToRgb(hex) {
|
|
|
|
| 821 |
return {r,g,b};
|
| 822 |
}
|
| 823 |
|
| 824 |
+
// ============================================================
|
| 825 |
+
// ZONE COLORS β colored neighborhood tints
|
| 826 |
+
// ============================================================
|
| 827 |
+
function drawZones(W, H) {
|
| 828 |
+
const isDark = currentTimeOfDay === 'night' || currentTimeOfDay === 'evening';
|
| 829 |
+
const isDawn = currentTimeOfDay === 'dawn';
|
| 830 |
+
|
| 831 |
+
const zones = [
|
| 832 |
+
// Park β bright grass green
|
| 833 |
+
{ cx: 0.50, cy: 0.19, rx: 0.11, ry: 0.055, c: isDark ? '#1a4a18' : (isDawn ? '#4a7a30' : '#7ecf50'), a: isDark ? 0.22 : 0.35 },
|
| 834 |
+
// Sports field β turf green
|
| 835 |
+
{ cx: 0.22, cy: 0.78, rx: 0.07, ry: 0.055, c: isDark ? '#1a3818' : '#5ab838', a: isDark ? 0.20 : 0.30 },
|
| 836 |
+
// Town square β warm cobblestone gold
|
| 837 |
+
{ cx: 0.50, cy: 0.50, rx: 0.07, ry: 0.045, c: isDark ? '#3a3010' : '#e8c86a', a: isDark ? 0.18 : 0.32 },
|
| 838 |
+
// Commercial row β warm terracotta orange
|
| 839 |
+
{ cx: 0.48, cy: 0.42, rx: 0.32, ry: 0.075, c: isDark ? '#2a1808' : '#e8b880', a: isDark ? 0.15 : 0.20 },
|
| 840 |
+
// North residential β soft warm cream
|
| 841 |
+
{ cx: 0.48, cy: 0.20, rx: 0.44, ry: 0.060, c: isDark ? '#28201a' : '#eedcb0', a: isDark ? 0.13 : 0.22 },
|
| 842 |
+
// South residential β light sandy beige
|
| 843 |
+
{ cx: 0.48, cy: 0.66, rx: 0.44, ry: 0.065, c: isDark ? '#28201a' : '#e8d8a0', a: isDark ? 0.13 : 0.22 },
|
| 844 |
+
// Office / business district β cool steel blue
|
| 845 |
+
{ cx: 0.66, cy: 0.34, rx: 0.22, ry: 0.075, c: isDark ? '#182030' : '#b0cce8', a: isDark ? 0.18 : 0.24 },
|
| 846 |
+
// Industrial β brownish rust
|
| 847 |
+
{ cx: 0.91, cy: 0.63, rx: 0.07, ry: 0.08, c: isDark ? '#201408' : '#c89860', a: isDark ? 0.20 : 0.28 },
|
| 848 |
+
// Hospital β clean pale cyan
|
| 849 |
+
{ cx: 0.91, cy: 0.50, rx: 0.07, ry: 0.055, c: isDark ? '#182828' : '#b8e8e0', a: isDark ? 0.18 : 0.26 },
|
| 850 |
+
// School β warm learning yellow
|
| 851 |
+
{ cx: 0.08, cy: 0.50, rx: 0.07, ry: 0.055, c: isDark ? '#28200a' : '#f0e890', a: isDark ? 0.16 : 0.26 },
|
| 852 |
+
// Church garden β soft lavender-green
|
| 853 |
+
{ cx: 0.08, cy: 0.78, rx: 0.06, ry: 0.045, c: isDark ? '#1a2030' : '#d0c8e8', a: isDark ? 0.18 : 0.28 },
|
| 854 |
+
];
|
| 855 |
+
|
| 856 |
+
for (const z of zones) {
|
| 857 |
+
const grd = ctx.createRadialGradient(z.cx*W, z.cy*H, 0, z.cx*W, z.cy*H, Math.max(z.rx*W, z.ry*H));
|
| 858 |
+
grd.addColorStop(0, z.c);
|
| 859 |
+
grd.addColorStop(1, 'rgba(0,0,0,0)');
|
| 860 |
+
ctx.globalAlpha = z.a;
|
| 861 |
+
ctx.fillStyle = grd;
|
| 862 |
+
ctx.beginPath();
|
| 863 |
+
ctx.ellipse(z.cx*W, z.cy*H, z.rx*W, z.ry*H, 0, 0, 6.28);
|
| 864 |
+
ctx.fill();
|
| 865 |
+
}
|
| 866 |
+
ctx.globalAlpha = 1.0;
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
// ============================================================
|
| 870 |
// WEATHER
|
| 871 |
// ============================================================
|
|
|
|
| 1664 |
|
| 1665 |
if (isSel) { ctx.shadowColor=color; ctx.shadowBlur=12; }
|
| 1666 |
|
| 1667 |
+
const walkAnim = isMoving || moving;
|
| 1668 |
+
const walkPhase = animFrame * 0.28;
|
| 1669 |
+
const bounce = walkAnim ? Math.sin(walkPhase) * 2.8 : 0;
|
| 1670 |
+
const armSwing = walkAnim ? Math.sin(walkPhase) * 18 : 0;
|
| 1671 |
+
const legSwing = walkAnim ? Math.sin(walkPhase) * 13 : 0;
|
| 1672 |
|
| 1673 |
// Ground shadow (isometric ellipse)
|
| 1674 |
ctx.fillStyle = 'rgba(0,0,0,0.18)';
|
|
|
|
| 1706 |
}
|
| 1707 |
|
| 1708 |
// Legs (with perspective offset)
|
| 1709 |
+
const legColor = gender==='female' ? skin : dim(color, 0.5);
|
| 1710 |
+
ctx.strokeStyle = legColor;
|
| 1711 |
+
ctx.lineWidth = walkAnim ? 2.5 : 1.8;
|
| 1712 |
+
const lx1 = walkAnim ? -3 - legSwing * 0.25 : -3;
|
| 1713 |
+
const lx2 = walkAnim ? 3 + legSwing * 0.25 : 3;
|
| 1714 |
+
const ly = walkAnim ? 11 : 8;
|
| 1715 |
+
ctx.beginPath(); ctx.moveTo(-2, 3+bounce); ctx.lineTo(lx1, ly+bounce+legSwing); ctx.stroke();
|
| 1716 |
+
ctx.beginPath(); ctx.moveTo( 2, 3+bounce); ctx.lineTo(lx2, ly+bounce-legSwing); ctx.stroke();
|
| 1717 |
|
| 1718 |
// Feet
|
| 1719 |
ctx.fillStyle = '#2a2a2a';
|
| 1720 |
+
ctx.fillRect(lx1-2, ly-1+bounce+legSwing, 4, 2);
|
| 1721 |
+
ctx.fillRect(lx2-1, ly-1+bounce-legSwing, 4, 2);
|
| 1722 |
|
| 1723 |
// Arms
|
| 1724 |
+
ctx.strokeStyle = skin;
|
| 1725 |
+
ctx.lineWidth = walkAnim ? 2.0 : 1.5;
|
| 1726 |
+
const armX1 = walkAnim ? -9 : -7;
|
| 1727 |
+
const armX2 = walkAnim ? 9 : 7;
|
| 1728 |
+
const armY = walkAnim ? 2 : 0;
|
| 1729 |
+
ctx.beginPath(); ctx.moveTo(-3.5, -6+bounce); ctx.lineTo(armX1, armY+bounce+armSwing); ctx.stroke();
|
| 1730 |
+
ctx.beginPath(); ctx.moveTo( 3.5, -6+bounce); ctx.lineTo(armX2, armY+bounce-armSwing); ctx.stroke();
|
| 1731 |
|
| 1732 |
// Head (slightly offset for 2.5D)
|
| 1733 |
ctx.fillStyle = skin;
|
|
|
|
| 1801 |
// ============================================================
|
| 1802 |
function onCanvasClick(e) {
|
| 1803 |
if (isDragging) return;
|
| 1804 |
+
if (_blockNextClick) { _blockNextClick = false; return; }
|
| 1805 |
const rect=canvas.getBoundingClientRect();
|
| 1806 |
+
const mx=(e.clientX-rect.left)/zoom+panX, my=(e.clientY-rect.top)/zoom+panY;
|
| 1807 |
let clicked=null, minD=24;
|
| 1808 |
for (const [id,pos] of Object.entries(agentPositions)) {
|
| 1809 |
const d=Math.hypot(mx-pos.x,my-pos.y);
|
|
|
|
| 1821 |
|
| 1822 |
function onCanvasMouseMove(e) {
|
| 1823 |
const rect=canvas.getBoundingClientRect();
|
| 1824 |
+
const mx=(e.clientX-rect.left)/zoom+panX, my=(e.clientY-rect.top)/zoom+panY;
|
| 1825 |
const W=worldW(), H=worldH();
|
| 1826 |
const tt=document.getElementById('tooltip');
|
| 1827 |
|
| 1828 |
+
if (isDragging || rectZoomMode) return;
|
| 1829 |
|
| 1830 |
let foundAgent=null;
|
| 1831 |
for (const [id,pos] of Object.entries(agentPositions)) {
|