RayMelius Claude Sonnet 4.6 commited on
Commit
bbf0b4e
Β·
1 Parent(s): 1d60530

Enhance web UI: 2.5D agents, road-waypoint movement, weekend badge

Browse files

- Rewrote drawPerson() for realistic 2.5D look: larger head with shine/mood
mouth, two-segment arms with elbow bend, two-segment legs with knee, shoes
via roundRect, 3-face isometric torso (front/side/top), belt, female skirt
- Moving agents now smoothly traverse road waypoints instead of teleporting;
lerp rate 0.022 for moving state, 0.07 for stationary
- Added agentPrevLocations + agentWaypoints; computeAgentTarget() inserts a
mid-road waypoint on location change during 'moving' state
- Weekend badge (gold pulsing pill) shown in top bar on days 6 & 7 of each week

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. web/index.html +269 -117
web/index.html CHANGED
@@ -23,8 +23,21 @@
23
  height: 50px;
24
  }
25
  #header h1 { font-size: 18px; color: #e94560; letter-spacing: 2px; }
26
- #header .info { display: flex; gap: 20px; font-size: 13px; color: #a0a0c0; }
27
  #header .info span { display: flex; align-items: center; gap: 6px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
29
  .dot.green { background: #4ecca3; } .dot.yellow { background: #f0c040; } .dot.red { background: #e94560; }
30
  #main { display: flex; height: calc(100vh - 50px); }
@@ -152,6 +165,7 @@
152
  <h1>SOCI CITY</h1>
153
  <div class="info">
154
  <span id="clock">Day 1, 06:00</span>
 
155
  <span><span id="weather-icon"></span> <span id="weather">Sunny</span></span>
156
  <span id="agent-count"><span class="dot green"></span> 0 agents</span>
157
  <span id="conv-count">0 convos</span>
@@ -346,6 +360,9 @@ let agentIdxMap = {};
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;
@@ -531,8 +548,21 @@ function animate() {
531
  for (const [id, target] of Object.entries(agentTargets)) {
532
  if (!agentPositions[id]) { agentPositions[id] = {...target}; continue; }
533
  const p = agentPositions[id];
534
- p.x += (target.x - p.x) * 0.06;
535
- p.y += (target.y - p.y) * 0.06;
 
 
 
 
 
 
 
 
 
 
 
 
 
536
  }
537
  draw();
538
  requestAnimationFrame(animate);
@@ -1638,6 +1668,30 @@ function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) {
1638
  agentTargets[id] = { x: pos.x * W + ox, y: pos.y * H + oy };
1639
  }
1640
  if (!agentPositions[id]) agentPositions[id] = {...agentTargets[id]};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1641
  }
1642
 
1643
  // ============================================================
@@ -1651,149 +1705,242 @@ function drawPerson(id, agent, globalIdx, W, H) {
1651
  const isSel = id === selectedAgentId;
1652
  const isHov = id === hoveredAgent;
1653
  const gender = agent.gender || 'unknown';
1654
- const scale = (isSel ? 1.0 : (isHov ? 0.9 : 0.72));
1655
  const isMoving = agent.state === 'moving';
1656
  const isSleeping = agent.state === 'sleeping';
1657
 
 
1658
  const tgt = agentTargets[id];
1659
- const moving = tgt && Math.hypot(ax-tgt.x, ay-tgt.y) > 3;
 
 
 
 
 
 
 
 
 
 
 
 
 
1660
 
1661
  ctx.save();
1662
  ctx.translate(ax, ay);
1663
  ctx.scale(scale, scale);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)';
1675
- ctx.beginPath(); ctx.ellipse(1, 10, 6, 2.5, 0.15, 0, 6.28); ctx.fill();
1676
-
1677
- // Determine skin tones for variety
1678
- const skinTones = ['#f0d0b0','#d4a574','#c68642','#8d5524','#e8c4a0','#f5d6b8'];
1679
- const skinIdx = (globalIdx * 7 + 3) % skinTones.length;
1680
- const skin = skinTones[skinIdx];
1681
-
1682
- // --- BODY (2.5D: front + slight right side visible) ---
1683
- // Torso β€” front face
1684
- if (gender==='female') {
1685
- ctx.fillStyle = color;
1686
- ctx.beginPath();
1687
- ctx.moveTo(-3, -8+bounce); ctx.lineTo(3, -8+bounce);
1688
- ctx.lineTo(5, 3+bounce); ctx.lineTo(-5, 3+bounce);
1689
- ctx.closePath(); ctx.fill();
1690
- // Torso side (2.5D)
1691
- ctx.fillStyle = dim(color, 0.7);
1692
- ctx.beginPath();
1693
- ctx.moveTo(3, -8+bounce); ctx.lineTo(5, -9+bounce);
1694
- ctx.lineTo(7, 2+bounce); ctx.lineTo(5, 3+bounce);
1695
- ctx.closePath(); ctx.fill();
1696
- } else {
1697
- // Male/NB β€” blocky torso
1698
- ctx.fillStyle = color;
1699
- ctx.fillRect(-3.5, -8+bounce, 7, 10);
1700
- // Torso side (2.5D)
1701
- ctx.fillStyle = dim(color, 0.65);
1702
  ctx.beginPath();
1703
- ctx.moveTo(3.5, -8+bounce); ctx.lineTo(5.5, -9.5+bounce);
1704
- ctx.lineTo(5.5, 0.5+bounce); ctx.lineTo(3.5, 2+bounce);
1705
  ctx.closePath(); ctx.fill();
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;
1734
- ctx.beginPath(); ctx.arc(0.5, -13+bounce, 5, 0, 6.28); ctx.fill();
1735
- // Head side shading
1736
- ctx.fillStyle = dim(skin, 0.85);
1737
- ctx.beginPath(); ctx.arc(2, -13+bounce, 4.5, -0.3, 1.2); ctx.fill();
1738
 
1739
- // Hair
1740
- const hairColor = dim(color, 0.5);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1741
  ctx.fillStyle = hairColor;
1742
- if (gender==='female') {
1743
- ctx.beginPath(); ctx.ellipse(0.5, -15.5+bounce, 6, 3.5, 0, Math.PI, 0); ctx.fill();
1744
- ctx.beginPath(); ctx.ellipse(-3, -11+bounce, 2, 5, 0.3, 0, 6.28); ctx.fill();
1745
- ctx.beginPath(); ctx.ellipse(4, -11+bounce, 2, 5, -0.3, 0, 6.28); ctx.fill();
1746
- } else if (gender==='male') {
1747
- ctx.beginPath(); ctx.ellipse(0.5, -15.5+bounce, 5.5, 3, 0, Math.PI, 0); ctx.fill();
 
 
 
1748
  } else {
1749
- ctx.beginPath(); ctx.ellipse(0.5, -15.5+bounce, 6, 3.5, 0, Math.PI, 0); ctx.fill();
1750
- ctx.beginPath(); ctx.ellipse(-3.5, -12+bounce, 1.5, 4, 0.2, 0, 6.28); ctx.fill();
1751
- }
1752
-
1753
- // Eyes (tiny dots)
1754
- ctx.fillStyle = '#222';
1755
- ctx.fillRect(-1.5, -14+bounce, 1, 1);
1756
- ctx.fillRect(2, -14+bounce, 1, 1);
1757
-
1758
- ctx.shadowColor='transparent'; ctx.shadowBlur=0;
1759
-
1760
- // State effects
1761
- if (agent.state==='in_conversation') {
1762
- ctx.fillStyle='rgba(240,192,64,0.85)';
1763
- ctx.beginPath(); ctx.ellipse(10, -17+bounce, 7, 5, 0, 0, 6.28); ctx.fill();
1764
- ctx.fillStyle='#1a1a2e'; ctx.font='bold 6px Segoe UI'; ctx.textAlign='center'; ctx.textBaseline='middle';
1765
- ctx.fillText('...', 10, -17+bounce);
 
 
 
 
 
 
 
 
 
 
 
 
 
1766
  }
1767
 
1768
  if (isSleeping) {
1769
- const t=animFrame*0.04;
1770
- ctx.font='bold 7px Segoe UI'; ctx.textAlign='left';
1771
- for (let i=0;i<3;i++) {
1772
- ctx.globalAlpha=0.3+i*0.25; ctx.fillStyle='#8ab4f8';
1773
- ctx.fillText('z', 7+i*3, -18-i*5+Math.sin(t+i)*2);
1774
  }
1775
- ctx.globalAlpha=1;
1776
  }
1777
 
1778
  if (agent.partner_id) {
1779
- drawHeart(0, -22+bounce+Math.sin(animFrame*0.04)*2, 3, 'rgba(233,30,144,0.7)');
 
 
 
 
 
 
 
 
1780
  }
1781
 
1782
  ctx.restore();
1783
 
1784
- // Name label (smaller)
1785
- const firstName = (agent.name||id).split(' ')[0];
1786
- ctx.font=`${isSel?'bold ':''}8px Segoe UI`; ctx.textAlign='center'; ctx.textBaseline='top';
1787
- ctx.fillStyle='rgba(0,0,0,0.45)'; ctx.fillText(firstName, ax+1, ay+10*scale+1);
1788
- ctx.fillStyle=isSel?'#fff':'#c0c0d0'; ctx.fillText(firstName, ax, ay+10*scale);
 
1789
 
1790
- // Mood bar (smaller)
1791
- const mood=agent.mood||0;
1792
- const mw=14, mx=ax-mw/2, my=ay+10*scale+11;
1793
- ctx.fillStyle='rgba(15,52,96,0.4)'; ctx.fillRect(mx,my,mw,2);
1794
- const mf=(mood+1)/2;
1795
- ctx.fillStyle=mf>0.6?'#4ecca3':(mf>0.3?'#f0c040':'#e94560');
1796
- ctx.fillRect(mx,my,mw*mf,2);
1797
  }
1798
 
1799
  // ============================================================
@@ -2078,7 +2225,12 @@ function processStateData(data) {
2078
  currentTimeOfDay = clock.time_of_day || 'morning';
2079
  currentWeather = (data.weather || 'sunny').toLowerCase();
2080
 
2081
- document.getElementById('clock').textContent = `Day ${clock.day||1}, ${clock.time_str||'??:??'} (${currentTimeOfDay})`;
 
 
 
 
 
2082
  document.getElementById('weather-icon').textContent = WEATHER_ICONS[currentWeather] || '\u2600\uFE0F';
2083
  document.getElementById('weather').textContent = currentWeather;
2084
  document.getElementById('agent-count').innerHTML = `<span class="dot green"></span> ${Object.keys(data.agents||{}).length} agents`;
 
23
  height: 50px;
24
  }
25
  #header h1 { font-size: 18px; color: #e94560; letter-spacing: 2px; }
26
+ #header .info { display: flex; gap: 20px; font-size: 13px; color: #a0a0c0; align-items: center; }
27
  #header .info span { display: flex; align-items: center; gap: 6px; }
28
+ #weekend-badge {
29
+ display: none; align-items: center; gap: 5px;
30
+ background: linear-gradient(135deg, #f0c040, #e67e22);
31
+ color: #1a1a2e; font-weight: 800; font-size: 11px;
32
+ padding: 3px 10px; border-radius: 12px;
33
+ letter-spacing: 1px; text-transform: uppercase;
34
+ box-shadow: 0 0 10px rgba(240,192,64,0.5);
35
+ animation: weekend-pulse 2s ease-in-out infinite;
36
+ }
37
+ @keyframes weekend-pulse {
38
+ 0%, 100% { box-shadow: 0 0 8px rgba(240,192,64,0.4); }
39
+ 50% { box-shadow: 0 0 18px rgba(240,192,64,0.8); }
40
+ }
41
  .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
42
  .dot.green { background: #4ecca3; } .dot.yellow { background: #f0c040; } .dot.red { background: #e94560; }
43
  #main { display: flex; height: calc(100vh - 50px); }
 
165
  <h1>SOCI CITY</h1>
166
  <div class="info">
167
  <span id="clock">Day 1, 06:00</span>
168
+ <span id="weekend-badge">πŸ–οΈ Weekend</span>
169
  <span><span id="weather-icon"></span> <span id="weather">Sunny</span></span>
170
  <span id="agent-count"><span class="dot green"></span> 0 agents</span>
171
  <span id="conv-count">0 convos</span>
 
360
  // Pan & zoom state
361
  let panX = 0, panY = 0;
362
  let zoom = 1.0;
363
+ // Movement tracking β€” previous location + road waypoints for smooth walking
364
+ let agentPrevLocations = {}; // {id: locationId} last known location
365
+ let agentWaypoints = {}; // {id: {x,y}} intermediate road point during transit
366
  let isDragging = false, dragStartX = 0, dragStartY = 0, dragPanStartX = 0, dragPanStartY = 0;
367
  // Rectangle-zoom state
368
  let rectZoomMode = false, isRectDragging = false, rectStart = null, rectEnd = null;
 
548
  for (const [id, target] of Object.entries(agentTargets)) {
549
  if (!agentPositions[id]) { agentPositions[id] = {...target}; continue; }
550
  const p = agentPositions[id];
551
+ const agent = agents[id];
552
+
553
+ // If there's a road waypoint, move toward it first
554
+ const wp = agentWaypoints[id];
555
+ if (wp) {
556
+ const d = Math.hypot(p.x - wp.x, p.y - wp.y);
557
+ if (d < 4) delete agentWaypoints[id]; // arrived at waypoint
558
+ }
559
+ const dest = agentWaypoints[id] || target;
560
+
561
+ // Moving agents travel slower so the walk is visible; others snap faster
562
+ const isMoving = agent && (agent.state === 'moving');
563
+ const lerpRate = isMoving ? 0.022 : 0.07;
564
+ p.x += (dest.x - p.x) * lerpRate;
565
+ p.y += (dest.y - p.y) * lerpRate;
566
  }
567
  draw();
568
  requestAnimationFrame(animate);
 
1668
  agentTargets[id] = { x: pos.x * W + ox, y: pos.y * H + oy };
1669
  }
1670
  if (!agentPositions[id]) agentPositions[id] = {...agentTargets[id]};
1671
+
1672
+ // ── Road-waypoint routing for moving agents ──────────────────
1673
+ // When location changes while moving, insert a road waypoint so
1674
+ // the agent walks along the street grid instead of cutting straight.
1675
+ const prevLoc = agentPrevLocations[id];
1676
+ if (agent.state === 'moving' && prevLoc && prevLoc !== loc) {
1677
+ const prevPos = allPos[prevLoc];
1678
+ if (prevPos && agentPositions[id]) {
1679
+ const midX = (prevPos.x + pos.x) / 2;
1680
+ const midY = (prevPos.y + pos.y) / 2;
1681
+ // Snap midpoint to nearest horizontal or vertical road
1682
+ const hRoads = [0.28, 0.43, 0.58, 0.72];
1683
+ const vRoads = [0.15, 0.30, 0.50, 0.70, 0.85];
1684
+ const nearH = hRoads.reduce((b, r) => Math.abs(midY - r) < Math.abs(midY - b) ? r : b, hRoads[0]);
1685
+ const nearV = vRoads.reduce((b, r) => Math.abs(midX - r) < Math.abs(midX - b) ? r : b, vRoads[0]);
1686
+ const useH = Math.abs(midY - nearH) <= Math.abs(midX - nearV);
1687
+ const jitter = (localIdx % 3 - 1) * 7; // lane spread
1688
+ agentWaypoints[id] = {
1689
+ x: (useH ? midX : nearV) * W + (useH ? jitter : 0),
1690
+ y: (useH ? nearH : midY) * H + (useH ? 0 : jitter),
1691
+ };
1692
+ }
1693
+ }
1694
+ agentPrevLocations[id] = loc;
1695
  }
1696
 
1697
  // ============================================================
 
1705
  const isSel = id === selectedAgentId;
1706
  const isHov = id === hoveredAgent;
1707
  const gender = agent.gender || 'unknown';
1708
+ const scale = isSel ? 1.15 : (isHov ? 1.0 : 0.82);
1709
  const isMoving = agent.state === 'moving';
1710
  const isSleeping = agent.state === 'sleeping';
1711
 
1712
+ // Visual movement = interpolated position is far from target
1713
  const tgt = agentTargets[id];
1714
+ const movingVisually = tgt && Math.hypot(ax - tgt.x, ay - tgt.y) > 4;
1715
+ const walkAnim = isMoving || movingVisually;
1716
+ const walkPhase = animFrame * 0.28;
1717
+ const bounce = walkAnim ? Math.sin(walkPhase) * 2.5 : 0;
1718
+ const legSwing = walkAnim ? Math.sin(walkPhase) * 12 : 0;
1719
+ const armSwing = walkAnim ? Math.sin(walkPhase) * 16 : 0;
1720
+
1721
+ // Skin & hair
1722
+ const skinTones = ['#f5dbb8','#d4a574','#c68642','#8d5524','#e8c4a0','#f0c090'];
1723
+ const skin = skinTones[(globalIdx * 7 + 3) % skinTones.length];
1724
+ const hairColor = dim(color, 0.45);
1725
+ // Pants = darker variant of shirt, shoes = very dark
1726
+ const pantsColor = dim(color, 0.55);
1727
+ const shoeColor = currentTimeOfDay === 'night' ? '#1a1a1a' : '#2a2010';
1728
 
1729
  ctx.save();
1730
  ctx.translate(ax, ay);
1731
  ctx.scale(scale, scale);
1732
+ if (isSel) { ctx.shadowColor = color; ctx.shadowBlur = 14; }
1733
+
1734
+ // ── Ground shadow (elongated, ISO-angled) ─────────────────
1735
+ ctx.fillStyle = 'rgba(0,0,0,0.22)';
1736
+ ctx.beginPath(); ctx.ellipse(3, 13, 8, 3, 0.2, 0, 6.28); ctx.fill();
1737
+
1738
+ // ── SHOES ────────────────────────────────────────────────
1739
+ const lKneeX = walkAnim ? -1.5 - legSwing * 0.18 : -1.5;
1740
+ const rKneeX = walkAnim ? 1.5 + legSwing * 0.18 : 1.5;
1741
+ const lFootX = walkAnim ? -2.5 - legSwing * 0.35 : -2.5;
1742
+ const rFootX = walkAnim ? 2.5 + legSwing * 0.35 : 2.5;
1743
+ const lFootY = 12 + bounce + legSwing;
1744
+ const rFootY = 12 + bounce - legSwing;
1745
+ // Shoe body
1746
+ ctx.fillStyle = shoeColor;
1747
+ ctx.beginPath(); ctx.roundRect(lFootX - 3, lFootY - 1, 5, 2.5, 1); ctx.fill();
1748
+ ctx.beginPath(); ctx.roundRect(rFootX - 1.5, rFootY - 1, 5, 2.5, 1); ctx.fill();
1749
+
1750
+ // ── LEGS (two-segment: thigh + shin with knee) ───────────
1751
+ ctx.lineWidth = walkAnim ? 2.8 : 2.2;
1752
+ ctx.lineCap = 'round';
1753
+ // Left leg
1754
+ ctx.strokeStyle = gender === 'female' ? dim(pantsColor, 0.85) : pantsColor;
1755
+ ctx.beginPath(); ctx.moveTo(-1.5, 4 + bounce);
1756
+ ctx.lineTo(lKneeX, 8 + bounce + legSwing * 0.5);
1757
+ ctx.lineTo(lFootX, lFootY); ctx.stroke();
1758
+ // Right leg
1759
+ ctx.beginPath(); ctx.moveTo(1.5, 4 + bounce);
1760
+ ctx.lineTo(rKneeX, 8 + bounce - legSwing * 0.5);
1761
+ ctx.lineTo(rFootX, rFootY); ctx.stroke();
1762
+ // Right leg 2.5D depth (slightly lighter)
1763
+ ctx.strokeStyle = dim(gender === 'female' ? dim(pantsColor, 0.85) : pantsColor, 1.2);
1764
+ ctx.lineWidth = 1.2;
1765
+ ctx.beginPath(); ctx.moveTo(1.5 + 1.5, 4 + bounce);
1766
+ ctx.lineTo(rKneeX + 1.5, 8 + bounce - legSwing * 0.5);
1767
+ ctx.lineTo(rFootX + 1.5, rFootY); ctx.stroke();
1768
+
1769
+ // ── TORSO (2.5D shirt with shoulder width + side face) ───
1770
+ const tY = -10 + bounce;
1771
+ const tw = gender === 'female' ? 7 : 8; // half-width at shoulders
1772
+ const bw = gender === 'female' ? 5 : 7; // half-width at waist
1773
 
1774
+ // Front face
1775
+ ctx.fillStyle = color;
1776
+ ctx.beginPath();
1777
+ ctx.moveTo(-tw, tY); ctx.lineTo(tw, tY);
1778
+ ctx.lineTo(bw, tY + 13); ctx.lineTo(-bw, tY + 13);
1779
+ ctx.closePath(); ctx.fill();
1780
 
1781
+ // Right side face (2.5D depth)
1782
+ ctx.fillStyle = dim(color, 0.6);
1783
+ ctx.beginPath();
1784
+ ctx.moveTo(tw, tY);
1785
+ ctx.lineTo(tw + 4, tY - 2);
1786
+ ctx.lineTo(bw + 4, tY + 11);
1787
+ ctx.lineTo(bw, tY + 13);
1788
+ ctx.closePath(); ctx.fill();
1789
+
1790
+ // Top face (shoulder plane)
1791
+ ctx.fillStyle = dim(color, 1.15 > 1 ? 1 : 1.15);
1792
+ ctx.beginPath();
1793
+ ctx.moveTo(-tw, tY);
1794
+ ctx.lineTo(-tw + 4, tY - 2);
1795
+ ctx.lineTo(tw + 4, tY - 2);
1796
+ ctx.lineTo(tw, tY);
1797
+ ctx.closePath(); ctx.fill();
1798
+
1799
+ // Collar / neckline
1800
+ ctx.fillStyle = dim(color, 0.75);
1801
+ ctx.beginPath(); ctx.ellipse(0.5, tY + 1, 2.5, 1.5, 0, 0, 6.28); ctx.fill();
1802
+
1803
+ // Belt line
1804
+ ctx.strokeStyle = dim(color, 0.4);
1805
+ ctx.lineWidth = 1;
1806
+ ctx.beginPath(); ctx.moveTo(-bw, tY + 12); ctx.lineTo(bw, tY + 12); ctx.stroke();
1807
+
1808
+ // Female: skirt flare
1809
+ if (gender === 'female') {
1810
+ ctx.fillStyle = dim(color, 0.8);
 
 
 
 
 
1811
  ctx.beginPath();
1812
+ ctx.moveTo(-bw, tY + 12); ctx.lineTo(-bw - 2, tY + 16 + bounce);
1813
+ ctx.lineTo(bw + 2, tY + 16 + bounce); ctx.lineTo(bw, tY + 12);
1814
  ctx.closePath(); ctx.fill();
1815
  }
1816
 
1817
+ // ── ARMS (two-segment with elbow when walking) ────────────
1818
+ const shoulderY = tY + 2;
1819
+ // Elbow positions
1820
+ const lElbowX = walkAnim ? -tw - 3 + armSwing * 0.3 : -tw - 3;
1821
+ const lElbowY = walkAnim ? shoulderY + 5 + armSwing * 0.4 : shoulderY + 5;
1822
+ const rElbowX = walkAnim ? tw + 3 - armSwing * 0.3 : tw + 3;
1823
+ const rElbowY = walkAnim ? shoulderY + 5 - armSwing * 0.4 : shoulderY + 5;
1824
+ // Hand positions
1825
+ const lHandX = walkAnim ? lElbowX - 2 + armSwing * 0.6 : lElbowX - 1;
1826
+ const lHandY = walkAnim ? lElbowY + 4 + armSwing * 0.8 : lElbowY + 4;
1827
+ const rHandX = walkAnim ? rElbowX + 2 - armSwing * 0.6 : rElbowX + 1;
1828
+ const rHandY = walkAnim ? rElbowY + 4 - armSwing * 0.8 : rElbowY + 4;
1829
+
1830
+ ctx.strokeStyle = skin; ctx.lineWidth = walkAnim ? 2.0 : 1.6; ctx.lineCap = 'round';
1831
+ // Left arm (upper arm + forearm)
1832
+ ctx.beginPath(); ctx.moveTo(-tw + 1, shoulderY);
1833
+ ctx.lineTo(lElbowX, lElbowY); ctx.lineTo(lHandX, lHandY); ctx.stroke();
1834
+ // Right arm
1835
+ ctx.beginPath(); ctx.moveTo(tw - 1, shoulderY);
1836
+ ctx.lineTo(rElbowX, rElbowY); ctx.lineTo(rHandX, rHandY); ctx.stroke();
1837
+ // Hands
 
 
 
 
1838
  ctx.fillStyle = skin;
1839
+ ctx.beginPath(); ctx.arc(lHandX, lHandY, 1.5, 0, 6.28); ctx.fill();
1840
+ ctx.beginPath(); ctx.arc(rHandX, rHandY, 1.5, 0, 6.28); ctx.fill();
 
 
1841
 
1842
+ // ── NECK ─────────────────────────────────────────────────
1843
+ ctx.fillStyle = skin;
1844
+ ctx.fillRect(-1.5, tY - 3.5, 3, 4);
1845
+
1846
+ // ── HEAD (2.5D β€” sphere-like with side shading) ───────────
1847
+ const hx = 1, hy = tY - 10; // head center (slightly right for 2.5D)
1848
+ const hr = 6.5;
1849
+ // Back-of-head / shading arc
1850
+ ctx.fillStyle = dim(skin, 0.78);
1851
+ ctx.beginPath(); ctx.arc(hx + 1.5, hy, hr - 0.5, 0, 6.28); ctx.fill();
1852
+ // Main face
1853
+ ctx.fillStyle = skin;
1854
+ ctx.beginPath(); ctx.arc(hx, hy, hr, 0, 6.28); ctx.fill();
1855
+ // Right cheek highlight (2.5D lit from upper-left)
1856
+ ctx.fillStyle = `rgba(255,255,255,0.12)`;
1857
+ ctx.beginPath(); ctx.arc(hx - 2, hy - 2, hr * 0.55, 0, 6.28); ctx.fill();
1858
+ // Ear (right side, 2.5D)
1859
+ ctx.fillStyle = dim(skin, 0.88);
1860
+ ctx.beginPath(); ctx.ellipse(hx + hr - 0.5, hy + 1, 1.5, 2, 0, 0, 6.28); ctx.fill();
1861
+
1862
+ // ── HAIR ─────────────────────────────────────────────────
1863
  ctx.fillStyle = hairColor;
1864
+ if (gender === 'female') {
1865
+ // Long hair β€” top cap + side curtains
1866
+ ctx.beginPath(); ctx.arc(hx, hy - 1, hr + 0.5, Math.PI * 1.1, Math.PI * 0.05); ctx.fill();
1867
+ ctx.beginPath(); ctx.ellipse(hx - hr + 1, hy + 3, 2.2, hr * 0.85, -0.2, 0, 6.28); ctx.fill();
1868
+ ctx.beginPath(); ctx.ellipse(hx + hr - 0.5, hy + 3, 1.8, hr * 0.7, 0.2, 0, 6.28); ctx.fill();
1869
+ } else if (gender === 'male') {
1870
+ // Short crop
1871
+ ctx.beginPath(); ctx.arc(hx, hy - 1, hr + 0.5, Math.PI * 1.15, Math.PI * -0.1); ctx.fill();
1872
+ ctx.fillRect(hx - hr - 0.5, hy - 1, 3, hr * 0.6); // left side
1873
  } else {
1874
+ // Medium / non-binary
1875
+ ctx.beginPath(); ctx.arc(hx, hy - 1, hr + 0.5, Math.PI * 1.08, Math.PI * 0.0); ctx.fill();
1876
+ ctx.beginPath(); ctx.ellipse(hx - hr + 0.5, hy + 2, 2, hr * 0.65, -0.15, 0, 6.28); ctx.fill();
1877
+ }
1878
+
1879
+ // ── FACE ─────────────────────────────────────────────────
1880
+ // Eyes
1881
+ ctx.fillStyle = '#1a1010';
1882
+ ctx.beginPath(); ctx.arc(hx - 2.2, hy - 1, 1.1, 0, 6.28); ctx.fill();
1883
+ ctx.beginPath(); ctx.arc(hx + 1.5, hy - 1, 1.1, 0, 6.28); ctx.fill();
1884
+ // Eye shine
1885
+ ctx.fillStyle = 'rgba(255,255,255,0.6)';
1886
+ ctx.beginPath(); ctx.arc(hx - 1.8, hy - 1.5, 0.45, 0, 6.28); ctx.fill();
1887
+ ctx.beginPath(); ctx.arc(hx + 1.9, hy - 1.5, 0.45, 0, 6.28); ctx.fill();
1888
+ // Mouth (tiny arc)
1889
+ const moodVal = agent.mood || 0;
1890
+ ctx.strokeStyle = dim(skin, 0.55); ctx.lineWidth = 0.8;
1891
+ ctx.beginPath(); ctx.arc(hx - 0.3, hy + 2.5, 1.8, 0.1, Math.PI - 0.1, moodVal < -0.2); ctx.stroke();
1892
+
1893
+ ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
1894
+
1895
+ // ── STATE EFFECTS ─────────────────────────────────────────
1896
+ if (agent.state === 'in_conversation') {
1897
+ ctx.fillStyle = 'rgba(240,192,64,0.9)';
1898
+ ctx.beginPath(); ctx.roundRect(hr + 3, hy - hr - 6, 16, 11, 3); ctx.fill();
1899
+ // speech tail
1900
+ ctx.beginPath(); ctx.moveTo(hr + 3, hy - hr - 2); ctx.lineTo(hr - 1, hy); ctx.lineTo(hr + 7, hy - hr - 2); ctx.fill();
1901
+ ctx.fillStyle = '#1a1a2e'; ctx.font = 'bold 6px Segoe UI';
1902
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1903
+ ctx.fillText('...', hr + 11, hy - hr);
1904
  }
1905
 
1906
  if (isSleeping) {
1907
+ const t = animFrame * 0.04;
1908
+ ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'left';
1909
+ for (let i = 0; i < 3; i++) {
1910
+ ctx.globalAlpha = 0.3 + i * 0.25; ctx.fillStyle = '#8ab4f8';
1911
+ ctx.fillText('z', hr + 2 + i * 4, hy - hr - i * 6 + Math.sin(t + i) * 2);
1912
  }
1913
+ ctx.globalAlpha = 1;
1914
  }
1915
 
1916
  if (agent.partner_id) {
1917
+ drawHeart(1, hy - hr - 10 + Math.sin(animFrame * 0.04) * 2, 3.5, 'rgba(233,30,144,0.8)');
1918
+ }
1919
+
1920
+ // Selected ring
1921
+ if (isSel) {
1922
+ ctx.strokeStyle = color; ctx.lineWidth = 1.5;
1923
+ ctx.setLineDash([3, 3]);
1924
+ ctx.beginPath(); ctx.arc(hx, hy, hr + 3, 0, 6.28); ctx.stroke();
1925
+ ctx.setLineDash([]);
1926
  }
1927
 
1928
  ctx.restore();
1929
 
1930
+ // ── Name label ───────────────────────────────────────────
1931
+ const firstName = (agent.name || id).split(' ')[0];
1932
+ ctx.font = `${isSel ? 'bold ' : ''}8px Segoe UI`;
1933
+ ctx.textAlign = 'center'; ctx.textBaseline = 'top';
1934
+ ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(firstName, ax + 1, ay + 14 * scale + 1);
1935
+ ctx.fillStyle = isSel ? '#fff' : '#c8c8d8'; ctx.fillText(firstName, ax, ay + 14 * scale);
1936
 
1937
+ // ── Mood bar ────────────────────────────────────���────────
1938
+ const moodV = agent.mood || 0;
1939
+ const mw = 16, mxb = ax - mw / 2, myb = ay + 14 * scale + 11;
1940
+ ctx.fillStyle = 'rgba(15,52,96,0.45)'; ctx.fillRect(mxb, myb, mw, 2.5);
1941
+ const mf = (moodV + 1) / 2;
1942
+ ctx.fillStyle = mf > 0.6 ? '#4ecca3' : (mf > 0.3 ? '#f0c040' : '#e94560');
1943
+ ctx.fillRect(mxb, myb, mw * mf, 2.5);
1944
  }
1945
 
1946
  // ============================================================
 
2225
  currentTimeOfDay = clock.time_of_day || 'morning';
2226
  currentWeather = (data.weather || 'sunny').toLowerCase();
2227
 
2228
+ const dayNum = clock.day || 1;
2229
+ const dayOfWeek = ((dayNum - 1) % 7) + 1; // 1=Mon … 7=Sun
2230
+ const isWeekend = dayOfWeek >= 6;
2231
+ document.getElementById('clock').textContent = `Day ${dayNum}, ${clock.time_str||'??:??'} (${currentTimeOfDay})`;
2232
+ const badge = document.getElementById('weekend-badge');
2233
+ badge.style.display = isWeekend ? 'flex' : 'none';
2234
  document.getElementById('weather-icon').textContent = WEATHER_ICONS[currentWeather] || '\u2600\uFE0F';
2235
  document.getElementById('weather').textContent = currentWeather;
2236
  document.getElementById('agent-count').innerHTML = `<span class="dot green"></span> ${Object.keys(data.agents||{}).length} agents`;