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>
- 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 |
-
|
| 535 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 1655 |
const isMoving = agent.state === 'moving';
|
| 1656 |
const isSleeping = agent.state === 'sleeping';
|
| 1657 |
|
|
|
|
| 1658 |
const tgt = agentTargets[id];
|
| 1659 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1660 |
|
| 1661 |
ctx.save();
|
| 1662 |
ctx.translate(ax, ay);
|
| 1663 |
ctx.scale(scale, scale);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1664 |
|
| 1665 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1666 |
|
| 1667 |
-
|
| 1668 |
-
|
| 1669 |
-
|
| 1670 |
-
|
| 1671 |
-
|
| 1672 |
-
|
| 1673 |
-
|
| 1674 |
-
ctx.
|
| 1675 |
-
|
| 1676 |
-
|
| 1677 |
-
|
| 1678 |
-
|
| 1679 |
-
|
| 1680 |
-
|
| 1681 |
-
|
| 1682 |
-
|
| 1683 |
-
|
| 1684 |
-
|
| 1685 |
-
|
| 1686 |
-
|
| 1687 |
-
|
| 1688 |
-
|
| 1689 |
-
|
| 1690 |
-
|
| 1691 |
-
|
| 1692 |
-
|
| 1693 |
-
|
| 1694 |
-
|
| 1695 |
-
|
| 1696 |
-
|
| 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(
|
| 1704 |
-
ctx.lineTo(
|
| 1705 |
ctx.closePath(); ctx.fill();
|
| 1706 |
}
|
| 1707 |
|
| 1708 |
-
//
|
| 1709 |
-
const
|
| 1710 |
-
|
| 1711 |
-
|
| 1712 |
-
const
|
| 1713 |
-
const
|
| 1714 |
-
const
|
| 1715 |
-
|
| 1716 |
-
|
| 1717 |
-
|
| 1718 |
-
|
| 1719 |
-
|
| 1720 |
-
|
| 1721 |
-
ctx.
|
| 1722 |
-
|
| 1723 |
-
|
| 1724 |
-
ctx.
|
| 1725 |
-
|
| 1726 |
-
|
| 1727 |
-
|
| 1728 |
-
|
| 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(
|
| 1735 |
-
|
| 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 |
-
//
|
| 1740 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1741 |
ctx.fillStyle = hairColor;
|
| 1742 |
-
if (gender==='female') {
|
| 1743 |
-
|
| 1744 |
-
ctx.beginPath(); ctx.
|
| 1745 |
-
ctx.beginPath(); ctx.ellipse(
|
| 1746 |
-
|
| 1747 |
-
|
|
|
|
|
|
|
|
|
|
| 1748 |
} else {
|
| 1749 |
-
|
| 1750 |
-
ctx.beginPath(); ctx.
|
| 1751 |
-
|
| 1752 |
-
|
| 1753 |
-
|
| 1754 |
-
|
| 1755 |
-
|
| 1756 |
-
ctx.
|
| 1757 |
-
|
| 1758 |
-
ctx.
|
| 1759 |
-
|
| 1760 |
-
|
| 1761 |
-
|
| 1762 |
-
|
| 1763 |
-
|
| 1764 |
-
|
| 1765 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1766 |
}
|
| 1767 |
|
| 1768 |
if (isSleeping) {
|
| 1769 |
-
const t=animFrame*0.04;
|
| 1770 |
-
ctx.font='bold
|
| 1771 |
-
for (let i=0;i<3;i++) {
|
| 1772 |
-
ctx.globalAlpha=0.3+i*0.25; ctx.fillStyle='#8ab4f8';
|
| 1773 |
-
ctx.fillText('z',
|
| 1774 |
}
|
| 1775 |
-
ctx.globalAlpha=1;
|
| 1776 |
}
|
| 1777 |
|
| 1778 |
if (agent.partner_id) {
|
| 1779 |
-
drawHeart(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1780 |
}
|
| 1781 |
|
| 1782 |
ctx.restore();
|
| 1783 |
|
| 1784 |
-
// Name label
|
| 1785 |
-
const firstName = (agent.name||id).split(' ')[0];
|
| 1786 |
-
ctx.font=`${isSel?'bold ':''}8px Segoe UI`;
|
| 1787 |
-
ctx.
|
| 1788 |
-
ctx.fillStyle=
|
|
|
|
| 1789 |
|
| 1790 |
-
// Mood bar
|
| 1791 |
-
const
|
| 1792 |
-
const mw=
|
| 1793 |
-
ctx.fillStyle='rgba(15,52,96,0.
|
| 1794 |
-
const mf=(
|
| 1795 |
-
ctx.fillStyle=mf>0.6?'#4ecca3':(mf>0.3?'#f0c040':'#e94560');
|
| 1796 |
-
ctx.fillRect(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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`;
|