| | <!DOCTYPE html> |
| | <html lang="en"> |
| | <head> |
| | <meta charset="UTF-8"> |
| | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| | <title>Soci City — Population Simulator</title> |
| | <style> |
| | * { margin: 0; padding: 0; box-sizing: border-box; } |
| | body { |
| | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| | background: #1a1a2e; |
| | color: #e0e0e0; |
| | overflow: hidden; |
| | height: 100vh; |
| | } |
| | #header { |
| | background: #16213e; |
| | padding: 10px 20px; |
| | display: flex; |
| | align-items: center; |
| | justify-content: space-between; |
| | border-bottom: 2px solid #0f3460; |
| | height: 50px; |
| | } |
| | #header h1 { font-size: 18px; color: #e94560; letter-spacing: 2px; } |
| | #header .info { display: flex; gap: 20px; font-size: 13px; color: #a0a0c0; align-items: center; } |
| | #header .info span { display: flex; align-items: center; gap: 6px; } |
| | #weekend-badge { |
| | display: none; align-items: center; gap: 5px; |
| | background: linear-gradient(135deg, #f0c040, #e67e22); |
| | color: #1a1a2e; font-weight: 800; font-size: 11px; |
| | padding: 3px 10px; border-radius: 12px; |
| | letter-spacing: 1px; text-transform: uppercase; |
| | box-shadow: 0 0 10px rgba(240,192,64,0.5); |
| | animation: weekend-pulse 2s ease-in-out infinite; |
| | } |
| | @keyframes weekend-pulse { |
| | 0%, 100% { box-shadow: 0 0 8px rgba(240,192,64,0.4); } |
| | 50% { box-shadow: 0 0 18px rgba(240,192,64,0.8); } |
| | } |
| | .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } |
| | .dot.green { background: #4ecca3; } .dot.yellow { background: #f0c040; } .dot.red { background: #e94560; } |
| | #main { display: flex; height: calc(100vh - 50px); } |
| | #canvas-container { flex: 1; position: relative; min-width: 0; } |
| | #cityCanvas { width: 100%; height: 100%; display: block; } |
| | |
| | |
| | #sidebar { |
| | width: 260px; background: #16213e; border-left: 2px solid #0f3460; |
| | display: flex; flex-direction: column; overflow: hidden; |
| | } |
| | .sidebar-tabs { |
| | display: flex; border-bottom: 2px solid #0f3460; flex-shrink: 0; |
| | } |
| | .sidebar-tab { |
| | flex: 1; padding: 8px 4px; text-align: center; font-size: 11px; font-weight: bold; |
| | color: #666; cursor: pointer; border-bottom: 2px solid transparent; |
| | transition: all 0.2s; text-transform: uppercase; letter-spacing: 0.5px; |
| | } |
| | .sidebar-tab:hover { color: #a0a0c0; background: rgba(255,255,255,0.02); } |
| | .sidebar-tab.active { color: #4ecca3; border-bottom-color: #4ecca3; } |
| | .tab-content { display: none; flex: 1; overflow-y: auto; } |
| | .tab-content.active { display: flex; flex-direction: column; } |
| | |
| | #agent-detail { padding: 12px; flex: 1; overflow-y: auto; } |
| | #agent-detail h2 { font-size: 16px; color: #4ecca3; margin-bottom: 8px; } |
| | #agent-detail .subtitle { font-size: 12px; color: #a0a0c0; margin-bottom: 6px; } |
| | .bar-container { margin: 3px 0; } |
| | .bar-label { font-size: 11px; color: #a0a0c0; display: flex; justify-content: space-between; } |
| | .bar-bg { height: 6px; background: #0f3460; border-radius: 3px; overflow: hidden; margin-top: 1px; } |
| | .bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; } |
| | .bar-fill.green { background: #4ecca3; } .bar-fill.yellow { background: #f0c040; } |
| | .bar-fill.red { background: #e94560; } .bar-fill.blue { background: #4e9eca; } |
| | .bar-fill.purple { background: #9b59b6; } .bar-fill.orange { background: #e67e22; } |
| | .bar-fill.pink { background: #e91e90; } |
| | .memory-item { font-size: 11px; color: #c0c0d0; padding: 3px 0; border-bottom: 1px solid #0f346030; } |
| | .memory-time { color: #666; font-size: 10px; } |
| | .agent-list-item { |
| | font-size: 11px; padding: 5px 6px; cursor: pointer; display: flex; align-items: center; gap: 6px; |
| | border-bottom: 1px solid #0f346020; transition: background 0.15s; |
| | } |
| | .agent-list-item:hover { background: rgba(78,204,163,0.08); } |
| | .agent-list-item .agent-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } |
| | .agent-list-item .agent-info { flex: 1; min-width: 0; } |
| | .agent-list-item .agent-name { font-weight: 600; } |
| | .agent-list-item .agent-action { color: #666; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| | |
| | #conversations-panel { padding: 0; flex: 1; overflow-y: auto; } |
| | .conv-card { |
| | margin: 8px; padding: 10px; background: #1a1a2e; border-radius: 6px; |
| | border: 1px solid #0f3460; |
| | } |
| | .conv-card.active-conv { border-color: #4ecca3; } |
| | .conv-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } |
| | .conv-topic { font-size: 12px; color: #4ecca3; font-weight: 600; } |
| | .conv-badge { font-size: 9px; padding: 2px 6px; border-radius: 3px; font-weight: bold; text-transform: uppercase; } |
| | .conv-badge.live { background: #4ecca3; color: #1a1a2e; } |
| | .conv-badge.ended { background: #333; color: #888; } |
| | .conv-participants { font-size: 10px; color: #888; margin-bottom: 6px; } |
| | .conv-turn { padding: 4px 0; font-size: 11px; line-height: 1.4; } |
| | .conv-speaker { font-weight: 600; } |
| | .conv-message { color: #d0d0e0; } |
| | .conv-empty { padding: 20px; text-align: center; color: #555; font-size: 12px; } |
| | |
| | #event-log-panel { padding: 8px 12px; flex: 1; overflow-y: auto; font-size: 11px; line-height: 1.5; } |
| | .event-line { padding: 2px 0; color: #b0b0c0; border-bottom: 1px solid #0f346020; } |
| | .event-line.plan { color: #4ecca3; } .event-line.conv { color: #f0c040; } |
| | .event-line.event { color: #e94560; } .event-line.time { color: #666; font-weight: bold; margin-top: 6px; } |
| | .event-line.romance { color: #e91e90; } |
| | .event-line.move { color: #4e9eca; } |
| | .event-line.reflect { color: #9b59b6; } |
| | |
| | #tooltip { |
| | position: absolute; background: #16213eee; border: 1px solid #4ecca3; |
| | border-radius: 6px; padding: 8px 12px; font-size: 12px; |
| | pointer-events: none; display: none; z-index: 100; max-width: 280px; |
| | } |
| | #toast-container { |
| | position: absolute; bottom: 12px; left: 12px; z-index: 200; |
| | display: flex; flex-direction: column-reverse; gap: 6px; |
| | pointer-events: none; max-width: 360px; |
| | } |
| | .toast { |
| | background: #16213eee; border-radius: 6px; padding: 8px 14px; |
| | font-size: 12px; color: #e0e0e0; border-left: 3px solid #4ecca3; |
| | animation: toastIn 0.3s ease, toastOut 0.5s ease 4.5s forwards; |
| | backdrop-filter: blur(4px); line-height: 1.4; |
| | } |
| | .toast.romance { border-left-color: #e91e90; } |
| | .toast.event { border-left-color: #e94560; } |
| | .toast.conv { border-left-color: #f0c040; } |
| | .toast.gossip { border-left-color: #9b59b6; } |
| | @keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } |
| | @keyframes toastOut { from { opacity: 1; } to { opacity: 0; } } |
| | #llm-model { cursor: pointer; user-select: none; } |
| | #llm-model:hover { color: #fff; } |
| | #llm-popup { |
| | display: none; position: fixed; z-index: 9999; |
| | background: #0d1b2e; border: 1px solid #1a3a6e; |
| | border-radius: 8px; padding: 6px 0; min-width: 200px; |
| | box-shadow: 0 6px 24px rgba(0,0,0,0.6); |
| | font-size: 13px; |
| | } |
| | #llm-popup .llm-pop-title { |
| | padding: 6px 14px 8px; color: #4ecca3; font-size: 11px; |
| | font-weight: 700; letter-spacing: 1px; border-bottom: 1px solid #0f3460; |
| | margin-bottom: 4px; |
| | } |
| | #llm-popup .llm-opt { |
| | display: flex; align-items: center; gap: 10px; flex-wrap: wrap; |
| | padding: 7px 14px; cursor: pointer; color: #c8c8d8; |
| | transition: background 0.15s; |
| | } |
| | #llm-popup .llm-opt:hover { background: #0f3460; color: #fff; } |
| | #llm-popup .llm-opt.active { color: #4ecca3; } |
| | #llm-popup .llm-opt .llm-check { width: 12px; text-align: center; font-size: 10px; } |
| | .section-header { |
| | font-size: 12px; color: #4ecca3; margin: 10px 0 4px 0; font-weight: 600; |
| | display: flex; align-items: center; gap: 6px; |
| | } |
| | .section-header::after { content: ''; flex: 1; height: 1px; background: #0f3460; } |
| | .rel-item { font-size: 11px; padding: 4px 0; border-bottom: 1px solid #0f346020; cursor: pointer; } |
| | .rel-item:hover { background: rgba(78,204,163,0.05); } |
| | .rel-name { font-weight: 600; color: #d0d0e0; } |
| | .rel-bars { display: flex; gap: 4px; margin-top: 2px; } |
| | .rel-mini-bar { flex: 1; height: 4px; background: #0f3460; border-radius: 2px; overflow: hidden; } |
| | .rel-mini-fill { height: 100%; border-radius: 2px; } |
| | |
| | .controls { display: flex; align-items: center; gap: 4px; } |
| | .ctrl-btn { |
| | background: #0f3460; border: 1px solid #1a3a6e; color: #a0a0c0; |
| | padding: 3px 8px; border-radius: 3px; cursor: pointer; font-size: 11px; |
| | transition: all 0.15s; |
| | } |
| | .ctrl-btn:hover { background: #1a4a80; color: #fff; } |
| | .ctrl-btn.active { background: #4ecca3; color: #1a1a2e; border-color: #4ecca3; } |
| | .ctrl-btn.paused { background: #e94560; color: #fff; border-color: #e94560; } |
| | .speed-label { font-size: 10px; color: #666; margin-left: 2px; } |
| | |
| | ::-webkit-scrollbar { width: 6px; } |
| | ::-webkit-scrollbar-track { background: #1a1a2e; } |
| | ::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; } |
| | |
| | |
| | .modal-overlay { |
| | position: fixed; inset: 0; background: rgba(0,0,0,0.72); |
| | display: flex; align-items: center; justify-content: center; z-index: 1000; |
| | } |
| | .modal-box { |
| | background: #0f1b35; border: 1px solid #1a3a6e; border-radius: 8px; |
| | padding: 24px 28px; min-width: 300px; max-width: 420px; width: 90%; |
| | color: #e0e0e0; |
| | } |
| | .modal-box h2 { color: #4ecca3; margin: 0 0 16px; font-size: 18px; } |
| | .modal-box label { display: block; font-size: 11px; color: #888; margin: 10px 0 3px; } |
| | .modal-box input, .modal-box textarea, .modal-box select { |
| | width: 100%; box-sizing: border-box; background: #0a1628; border: 1px solid #1a3a6e; |
| | color: #e0e0e0; padding: 7px 10px; border-radius: 4px; font-size: 13px; |
| | } |
| | .modal-box textarea { resize: vertical; min-height: 60px; font-family: inherit; } |
| | .modal-box input[type=range] { padding: 4px 0; border: none; background: none; } |
| | .modal-row { display: flex; gap: 10px; } |
| | .modal-row > * { flex: 1; } |
| | .modal-actions { display: flex; gap: 10px; margin-top: 18px; justify-content: flex-end; } |
| | .btn-primary { |
| | background: #4ecca3; color: #0a1628; border: none; padding: 8px 18px; |
| | border-radius: 4px; cursor: pointer; font-weight: 700; font-size: 13px; |
| | } |
| | .btn-primary:hover { background: #3ab88e; } |
| | .btn-secondary { |
| | background: transparent; color: #a0a0c0; border: 1px solid #1a3a6e; |
| | padding: 8px 18px; border-radius: 4px; cursor: pointer; font-size: 13px; |
| | } |
| | .btn-secondary:hover { color: #e0e0e0; border-color: #4ecca3; } |
| | .btn-danger { |
| | background: transparent; color: #e94560; border: 1px solid #e94560; |
| | padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 11px; |
| | } |
| | .auth-switch { text-align: center; margin-top: 12px; font-size: 12px; color: #666; } |
| | .auth-switch a { color: #4ecca3; cursor: pointer; text-decoration: underline; } |
| | .auth-error { color: #e94560; font-size: 12px; margin-top: 8px; } |
| | #player-panel { |
| | background: rgba(78,204,163,0.06); border: 1px solid rgba(78,204,163,0.2); |
| | border-radius: 6px; margin: 8px; padding: 10px 12px; display: none; |
| | } |
| | #player-panel .pp-header { |
| | display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; |
| | } |
| | #player-panel .pp-title { color: #4ecca3; font-size: 11px; font-weight: 700; letter-spacing: 1px; } |
| | #player-panel .pp-name { color: #e0e0e0; font-size: 13px; font-weight: 600; } |
| | #player-panel .pp-loc { color: #888; font-size: 10px; } |
| | .pp-actions { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } |
| | .pp-btn { |
| | background: #0f3460; color: #a0a0c0; border: 1px solid #1a3a6e; |
| | padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 11px; |
| | } |
| | .pp-btn:hover { background: #1a4a80; color: #e0e0e0; } |
| | .pp-move { display: flex; gap: 4px; margin-top: 6px; align-items: center; } |
| | .pp-move select { flex: 1; background: #0a1628; color: #e0e0e0; border: 1px solid #1a3a6e; padding: 4px 6px; border-radius: 3px; font-size: 11px; } |
| | .pp-move button { background: #4ecca3; color: #0a1628; border: none; padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 11px; font-weight: 700; } |
| | |
| | #chat-panel { |
| | border-top: 1px solid #1a3a6e; margin-top: 10px; padding-top: 10px; display: none; |
| | } |
| | #chat-panel .chat-header { color: #4ecca3; font-size: 11px; font-weight: 700; margin-bottom: 6px; display: flex; justify-content: space-between; } |
| | #chat-messages { max-height: 180px; overflow-y: auto; font-size: 12px; margin-bottom: 8px; } |
| | .chat-msg-you { color: #4ecca3; margin: 3px 0; } |
| | .chat-msg-npc { color: #e0e0e0; margin: 3px 0; } |
| | .chat-msg-thinking { color: #666; font-style: italic; margin: 3px 0; } |
| | #chat-input-row { display: flex; gap: 6px; } |
| | #chat-input { flex: 1; background: #0a1628; color: #e0e0e0; border: 1px solid #1a3a6e; padding: 6px 8px; border-radius: 3px; font-size: 12px; font-family: inherit; } |
| | #chat-send { background: #4ecca3; color: #0a1628; border: none; padding: 6px 12px; border-radius: 3px; cursor: pointer; font-weight: 700; font-size: 12px; } |
| | .slider-val { font-size: 12px; color: #4ecca3; min-width: 20px; text-align: right; } |
| | </style> |
| | </head> |
| | <body> |
| | <div id="header"> |
| | <h1>SOCI CITY</h1> |
| | <div class="info"> |
| | <span id="clock">Day 1, 06:00</span> |
| | <span id="weekend-badge">🏖️ Weekend</span> |
| | <span><span id="weather-icon"></span> <span id="weather">Sunny</span></span> |
| | <span id="agent-count"><span class="dot green"></span> 0 agents</span> |
| | <span id="conv-count">0 convos</span> |
| | <span id="llm-model" title="Active LLM model">⚡ —</span> |
| | <span class="controls"> |
| | <button class="ctrl-btn" id="btn-pause" onclick="togglePause()" title="Pause/Resume">⏯</button> |
| | <button class="ctrl-btn" id="btn-slow" onclick="setSpeed(3.0)" title="Slow">🐢</button> |
| | <button class="ctrl-btn active" id="btn-1x" onclick="setSpeed(1.0)" title="Normal">1x</button> |
| | <button class="ctrl-btn" id="btn-2x" onclick="setSpeed(0.5)" title="2x speed">2x</button> |
| | <button class="ctrl-btn" id="btn-5x" onclick="setSpeed(0.2)" title="5x speed">5x</button> |
| | <button class="ctrl-btn" id="btn-10x" onclick="setSpeed(0.1)" title="10x speed">10x</button> |
| | <button class="ctrl-btn" id="btn-50x" onclick="setSpeed(0.02)" title="50x speed">50x</button> |
| | <span class="speed-label" id="speed-label">1x</span> |
| | <span style="color:#1a3a6e;margin:0 4px">│</span> |
| | <span style="font-size:10px;color:#666;white-space:nowrap" title="LLM call probability: controls how often agents use AI reasoning vs. routine behaviour. At 45% with Gemini free tier ≈ 10h daily runtime.">🧠</span> |
| | <input type="range" id="llm-prob-slider" min="0" max="100" value="10" step="5" |
| | style="width:64px;height:6px;accent-color:#4ecca3;cursor:pointer;vertical-align:middle;" |
| | oninput="onLlmProbSlider(this.value)" title="LLM usage probability"> |
| | <span id="llm-prob-label" style="font-size:10px;color:#4ecca3;min-width:28px;text-align:right;">10%</span> |
| | <span style="color:#1a3a6e;margin:0 4px">│</span> |
| | <button class="ctrl-btn" id="btn-rect-zoom" onclick="toggleRectZoom()" title="Draw a rectangle to zoom into that area (Shift+drag)">⬚</button> |
| | <button class="ctrl-btn" onclick="zoomBy(1.3)" title="Zoom In (scroll up)">+</button> |
| | <button class="ctrl-btn" onclick="zoomBy(1/1.3)" title="Zoom Out (scroll down)">-</button> |
| | <button class="ctrl-btn" onclick="zoomFit()" title="Fit entire city on screen">Fit</button> |
| | </span> |
| | <span id="api-calls">API: 0</span> |
| | <span id="cost">$0.00</span> |
| | <span id="status"><span class="dot yellow"></span> Connecting...</span> |
| | </div> |
| | </div> |
| | <div id="main"> |
| | <div id="canvas-container"> |
| | <canvas id="cityCanvas"></canvas> |
| | <div id="tooltip"></div> |
| | <div id="toast-container"></div> |
| | <div id="llm-popup"><div class="llm-pop-title">SWITCH LLM</div></div> |
| | <input type="range" id="pan-x" min="0" max="100" value="0" |
| | style="position:absolute;bottom:4px;left:10px;right:10px;width:calc(100% - 20px);height:14px;opacity:0.5;z-index:50;" |
| | oninput="onPanSlider()"> |
| | <input type="range" id="pan-y" min="0" max="100" value="0" |
| | orient="vertical" |
| | style="position:absolute;right:4px;top:10px;bottom:24px;width:14px;height:calc(100% - 34px);opacity:0.5;z-index:50;writing-mode:vertical-lr;direction:ltr;-webkit-appearance:slider-vertical;" |
| | oninput="onPanSlider()"> |
| | </div> |
| | <div id="sidebar"> |
| | <div class="sidebar-tabs"> |
| | <div class="sidebar-tab active" data-tab="agents" onclick="switchTab('agents')">Agents</div> |
| | <div class="sidebar-tab" data-tab="conversations" onclick="switchTab('conversations')">Chat</div> |
| | <div class="sidebar-tab" data-tab="events" onclick="switchTab('events')">Events</div> |
| | </div> |
| | <div id="tab-agents" class="tab-content active"> |
| | |
| | <div id="player-panel"> |
| | <div class="pp-header"> |
| | <span class="pp-title">YOU</span> |
| | <button class="btn-danger" onclick="authLogout()" style="padding:2px 8px;font-size:10px">Logout</button> |
| | </div> |
| | <div id="pp-name" class="pp-name">Loading...</div> |
| | <div id="pp-loc" class="pp-loc"></div> |
| | <div class="pp-move"> |
| | <select id="pp-move-select"></select> |
| | <button onclick="playerMove()">Go</button> |
| | </div> |
| | <div class="pp-actions"> |
| | <button class="pp-btn" onclick="openProfileEditor()">Edit Profile</button> |
| | <button class="pp-btn" onclick="openPlansModal()">My Plans</button> |
| | <button class="pp-btn" onclick="openStockExModal()" style="background:#1a3a2e;border-color:#2a6a4e">StockEx</button> |
| | </div> |
| | </div> |
| | <div id="agent-detail"></div> |
| | </div> |
| | <div id="tab-conversations" class="tab-content"> |
| | <div id="conversations-panel"></div> |
| | </div> |
| | <div id="tab-events" class="tab-content"> |
| | <div id="event-log-panel"></div> |
| | </div> |
| | </div> |
| | </div> |
| | |
| | <div id="login-modal" class="modal-overlay" style="display:none"> |
| | <div class="modal-box"> |
| | <h2 id="auth-title">Welcome to Soci City</h2> |
| | <div id="auth-error" class="auth-error" style="display:none"></div> |
| | <label>Username</label> |
| | <input type="text" id="auth-username" placeholder="Your name" autocomplete="username"> |
| | <label>Password</label> |
| | <input type="password" id="auth-password" placeholder="Password" autocomplete="current-password"> |
| | <div class="modal-actions"> |
| | <button class="btn-secondary" onclick="closeLoginModal()">Skip</button> |
| | <button class="btn-primary" id="auth-btn" onclick="authSubmit()">Login</button> |
| | </div> |
| | <div class="auth-switch" id="auth-switch-text"> |
| | No account? <a onclick="toggleAuthMode()">Register</a> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div id="profile-modal" class="modal-overlay" style="display:none"> |
| | <div class="modal-box"> |
| | <h2>Your Agent Profile</h2> |
| | <div class="modal-row"> |
| | <div><label>Name</label><input type="text" id="pe-name" placeholder="Your name"></div> |
| | <div><label>Age</label><input type="number" id="pe-age" min="16" max="90" value="30"></div> |
| | </div> |
| | <div class="modal-row"> |
| | <div style="flex:1"> |
| | <label>Gender</label> |
| | <select id="pe-gender" style="width:100%;padding:6px 8px;background:#1e1e30;border:1px solid #3a3a5c;border-radius:6px;color:#e0e0f0;font-size:13px"> |
| | <option value="unknown">Prefer not to say</option> |
| | <option value="male">Male</option> |
| | <option value="female">Female</option> |
| | <option value="nonbinary">Non-binary</option> |
| | </select> |
| | </div> |
| | <div style="flex:1"> |
| | <label>Occupation</label> |
| | <input type="text" id="pe-occupation" placeholder="e.g. Artist, Engineer, Chef" style="width:100%"> |
| | </div> |
| | </div> |
| | <label>Background (how you describe yourself)</label> |
| | <textarea id="pe-background" rows="3" placeholder="A few sentences about yourself..."></textarea> |
| | <label>Extraversion: <span id="pe-extra-val" class="slider-val">5</span></label> |
| | <input type="range" id="pe-extraversion" min="1" max="10" value="5" oninput="document.getElementById('pe-extra-val').textContent=this.value"> |
| | <label>Agreeableness: <span id="pe-agree-val" class="slider-val">7</span></label> |
| | <input type="range" id="pe-agreeableness" min="1" max="10" value="7" oninput="document.getElementById('pe-agree-val').textContent=this.value"> |
| | <label>Openness: <span id="pe-open-val" class="slider-val">6</span></label> |
| | <input type="range" id="pe-openness" min="1" max="10" value="6" oninput="document.getElementById('pe-open-val').textContent=this.value"> |
| | <div class="modal-actions"> |
| | <button class="btn-secondary" onclick="document.getElementById('profile-modal').style.display='none'">Cancel</button> |
| | <button class="btn-primary" onclick="saveProfile()">Save</button> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div id="plans-modal" class="modal-overlay" style="display:none"> |
| | <div class="modal-box"> |
| | <h2>My Plans</h2> |
| | <div id="plans-list" style="font-size:12px;color:#a0a0c0;margin-bottom:12px;min-height:40px"></div> |
| | <label>Add a plan</label> |
| | <input type="text" id="plans-input" placeholder="e.g. Go to the cafe and meet someone" onkeydown="if(event.key==='Enter')addPlan()"> |
| | <div class="modal-actions"> |
| | <button class="btn-secondary" onclick="document.getElementById('plans-modal').style.display='none'">Close</button> |
| | <button class="btn-primary" onclick="addPlan()">Add</button> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | |
| | <div id="stockex-modal" class="modal-overlay" style="display:none"> |
| | <div class="modal-box" style="max-width:480px"> |
| | <h2 style="color:#4ecca3">StockEx Trading</h2> |
| | <div id="stockex-status" style="font-size:11px;color:#888;margin-bottom:8px"></div> |
| |
|
| | <div id="stockex-portfolio" style="font-size:11px;color:#a0a0c0;margin-bottom:10px;max-height:120px;overflow-y:auto"></div> |
| |
|
| | <div class="modal-row"> |
| | <div style="flex:1"> |
| | <label>Member ID</label> |
| | <input type="text" id="sx-member" placeholder="USR02" value="USR02" style="text-transform:uppercase"> |
| | </div> |
| | <div style="flex:1"> |
| | <label>Symbol</label> |
| | <select id="sx-symbol" style="width:100%;padding:6px 8px;background:#1e1e30;border:1px solid #3a3a5c;border-radius:6px;color:#e0e0f0;font-size:13px"> |
| | <option>Loading...</option> |
| | </select> |
| | </div> |
| | </div> |
| | <div class="modal-row"> |
| | <div style="flex:1"> |
| | <label>Side</label> |
| | <select id="sx-side" style="width:100%;padding:6px 8px;background:#1e1e30;border:1px solid #3a3a5c;border-radius:6px;color:#e0e0f0;font-size:13px"> |
| | <option value="BUY">BUY</option> |
| | <option value="SELL">SELL</option> |
| | </select> |
| | </div> |
| | <div style="flex:1"> |
| | <label>Quantity</label> |
| | <input type="number" id="sx-qty" min="1" value="100"> |
| | </div> |
| | <div style="flex:1"> |
| | <label>Price</label> |
| | <input type="number" id="sx-price" step="0.01" min="0.01" value="0"> |
| | </div> |
| | </div> |
| | <div id="sx-market-info" style="font-size:10px;color:#555;margin:4px 0"></div> |
| | <div id="sx-result" style="font-size:11px;margin:6px 0;min-height:18px"></div> |
| | <div class="modal-actions"> |
| | <button class="btn-secondary" onclick="document.getElementById('stockex-modal').style.display='none'">Close</button> |
| | <button class="btn-secondary" onclick="loadStockExPortfolio()">Refresh</button> |
| | <button class="btn-primary" onclick="submitStockExOrder()" style="background:#1a6a3e">Place Order</button> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | <script> |
| | |
| | |
| | |
| | const API_BASE = window.location.origin + '/api'; |
| | const POLL_INTERVAL = 2000; |
| | const HORIZON = 0.14; |
| | |
| | |
| | |
| | const WORLD_W = 1.0; |
| | const WORLD_H = 1.0; |
| | const MIN_ZOOM = 0.5; |
| | const MAX_ZOOM = 10.0; |
| | |
| | |
| | const ROADS = [ |
| | |
| | { x1: 0.03, y1: 0.22, x2: 0.97, y2: 0.22, width: 10, name: 'North Access' }, |
| | { x1: 0.03, y1: 0.28, x2: 0.97, y2: 0.28, width: 14, name: 'North Road' }, |
| | { x1: 0.03, y1: 0.43, x2: 0.97, y2: 0.43, width: 10, name: 'Mid Road' }, |
| | { x1: 0.03, y1: 0.58, x2: 0.97, y2: 0.58, width: 14, name: 'South Road' }, |
| | { x1: 0.03, y1: 0.72, x2: 0.97, y2: 0.72, width: 10, name: 'South Connector' }, |
| | { x1: 0.03, y1: 0.80, x2: 0.97, y2: 0.80, width: 10, name: 'South Access' }, |
| | |
| | { x1: 0.08, y1: 0.20, x2: 0.08, y2: 0.82, width: 7 }, |
| | { x1: 0.15, y1: 0.20, x2: 0.15, y2: 0.82, width: 10 }, |
| | { x1: 0.30, y1: 0.20, x2: 0.30, y2: 0.82, width: 8 }, |
| | { x1: 0.50, y1: 0.13, x2: 0.50, y2: 0.90, width: 16, name: 'Main Street' }, |
| | { x1: 0.70, y1: 0.20, x2: 0.70, y2: 0.82, width: 8 }, |
| | { x1: 0.85, y1: 0.20, x2: 0.85, y2: 0.82, width: 10 }, |
| | { x1: 0.92, y1: 0.20, x2: 0.92, y2: 0.82, width: 7 }, |
| | ]; |
| | |
| | |
| | |
| | const GEN_HOUSE_SLOTS = [ |
| | |
| | {x:0.04,y:0.21},{x:0.16,y:0.21},{x:0.30,y:0.21},{x:0.54,y:0.21},{x:0.70,y:0.21},{x:0.86,y:0.21},{x:0.94,y:0.21}, |
| | {x:0.04,y:0.27},{x:0.16,y:0.27},{x:0.30,y:0.27},{x:0.44,y:0.27},{x:0.56,y:0.27},{x:0.70,y:0.27},{x:0.84,y:0.27},{x:0.94,y:0.27}, |
| | |
| | {x:0.03,y:0.36},{x:0.03,y:0.42},{x:0.03,y:0.56},{x:0.03,y:0.62}, |
| | |
| | {x:0.96,y:0.36},{x:0.96,y:0.42},{x:0.96,y:0.56},{x:0.96,y:0.62}, |
| | |
| | {x:0.14,y:0.40},{x:0.44,y:0.40},{x:0.72,y:0.40},{x:0.84,y:0.40}, |
| | |
| | {x:0.14,y:0.56},{x:0.30,y:0.56},{x:0.44,y:0.56},{x:0.70,y:0.56},{x:0.82,y:0.56}, |
| | |
| | {x:0.04,y:0.66},{x:0.30,y:0.66},{x:0.54,y:0.66},{x:0.70,y:0.66},{x:0.86,y:0.66},{x:0.94,y:0.66}, |
| | {x:0.04,y:0.72},{x:0.16,y:0.72},{x:0.30,y:0.72},{x:0.46,y:0.72},{x:0.56,y:0.72},{x:0.70,y:0.72},{x:0.84,y:0.72},{x:0.94,y:0.72}, |
| | |
| | {x:0.14,y:0.82},{x:0.30,y:0.82},{x:0.44,y:0.82},{x:0.56,y:0.82},{x:0.70,y:0.82},{x:0.86,y:0.82},{x:0.96,y:0.82}, |
| | ]; |
| | |
| | |
| | const LOCATION_POSITIONS = { |
| | |
| | house_elena: { x: 0.08, y: 0.19, type: 'house', label: 'Elena & Lila' }, |
| | house_marcus: { x: 0.24, y: 0.19, type: 'house', label: 'Marcus & Zoe' }, |
| | house_helen: { x: 0.62, y: 0.19, type: 'house', label: 'Helen & Alice' }, |
| | house_diana: { x: 0.78, y: 0.19, type: 'house', label: 'Diana & Marco' }, |
| | |
| | house_kai: { x: 0.08, y: 0.36, type: 'house', label: "Kai's Studio" }, |
| | house_priya: { x: 0.92, y: 0.36, type: 'house', label: 'Priya & Nina' }, |
| | |
| | house_james: { x: 0.08, y: 0.65, type: 'house', label: 'James & Theo' }, |
| | house_rosa: { x: 0.24, y: 0.65, type: 'house', label: 'Rosa & Omar' }, |
| | house_yuki: { x: 0.62, y: 0.65, type: 'house', label: 'Yuki & Devon' }, |
| | house_frank: { x: 0.78, y: 0.65, type: 'house', label: 'Frank+George+Sam' }, |
| | |
| | apartment_block_1: { x: 0.40, y: 0.19, type: 'apartment', label: 'Northside Apts' }, |
| | apartment_block_2: { x: 0.40, y: 0.65, type: 'apartment', label: 'Southside Apts' }, |
| | apartment_block_3: { x: 0.56, y: 0.50, type: 'apartment', label: 'Central Apts' }, |
| | |
| | |
| | cafe: { x: 0.35, y: 0.34, type: 'shop', label: 'The Daily Grind' }, |
| | grocery: { x: 0.65, y: 0.34, type: 'shop', label: 'Green Basket' }, |
| | bakery: { x: 0.22, y: 0.34, type: 'shop', label: 'Golden Crust' }, |
| | restaurant: { x: 0.35, y: 0.50, type: 'shop', label: "Mama Rosa's" }, |
| | bar: { x: 0.65, y: 0.50, type: 'shop', label: 'Rusty Anchor' }, |
| | cinema: { x: 0.78, y: 0.50, type: 'cinema', label: 'Starlight Cinema' }, |
| | |
| | |
| | office: { x: 0.50, y: 0.34, type: 'office', label: 'The Hive' }, |
| | office_tower: { x: 0.80, y: 0.34, type: 'tower', label: 'Pinnacle Tower' }, |
| | factory: { x: 0.92, y: 0.65, type: 'factory', label: 'Ironworks' }, |
| | school: { x: 0.08, y: 0.50, type: 'school', label: 'Soci School' }, |
| | hospital: { x: 0.92, y: 0.50, type: 'hospital', label: 'City Hospital' }, |
| | |
| | |
| | park: { x: 0.50, y: 0.19, type: 'park', label: 'Willow Park' }, |
| | gym: { x: 0.22, y: 0.50, type: 'public', label: 'Iron & Grit' }, |
| | library: { x: 0.78, y: 0.78, type: 'public', label: 'Public Library' }, |
| | church: { x: 0.08, y: 0.78, type: 'church', label: "St. Mary's" }, |
| | town_square: { x: 0.50, y: 0.50, type: 'square', label: 'Town Square' }, |
| | sports_field: { x: 0.22, y: 0.78, type: 'sports', label: 'Sports Field' }, |
| | |
| | |
| | apt_northeast: { x: 0.93, y: 0.22, type: 'apartment', label: 'Eastview Terrace' }, |
| | apt_northwest: { x: 0.07, y: 0.22, type: 'apartment', label: 'Hilltop Gardens' }, |
| | apt_southeast: { x: 0.93, y: 0.78, type: 'apartment', label: 'Riverside Commons' }, |
| | apt_southwest: { x: 0.07, y: 0.78, type: 'apartment', label: 'Orchard Hill Flats' }, |
| | diner: { x: 0.92, y: 0.36, type: 'shop', label: 'Blue Moon Diner' }, |
| | pharmacy: { x: 0.35, y: 0.78, type: 'shop', label: 'SociMed Pharmacy' }, |
| | |
| | |
| | street_north: { x: 0.50, y: 0.28, type: 'street', label: 'N. Main St' }, |
| | street_south: { x: 0.50, y: 0.58, type: 'street', label: 'S. Main St' }, |
| | street_east: { x: 0.85, y: 0.43, type: 'street', label: 'East Ave' }, |
| | street_west: { x: 0.15, y: 0.43, type: 'street', label: 'West Ave' }, |
| | }; |
| | |
| | const AGENT_COLORS = [ |
| | '#e94560','#4ecca3','#f0c040','#4e9eca','#9b59b6', |
| | '#e67e22','#1abc9c','#e74c3c','#3498db','#2ecc71', |
| | '#f39c12','#8e44ad','#16a085','#c0392b','#2980b9', |
| | '#27ae60','#d35400','#7d3c98','#148f77','#cb4335', |
| | ]; |
| | |
| | const WEATHER_ICONS = { |
| | sunny:'\u2600\uFE0F', clear:'\u2600\uFE0F', cloudy:'\u2601\uFE0F', |
| | rainy:'\uD83C\uDF27\uFE0F', stormy:'\u26C8\uFE0F', foggy:'\uD83C\uDF2B\uFE0F', |
| | }; |
| | |
| | const SKY = { |
| | dawn: { top:'#2d1b4e', bot:'#e8a860', stars:false, sun:'low' }, |
| | morning: { top:'#4a90c8', bot:'#c8e0f0', stars:false, sun:'mid' }, |
| | afternoon: { top:'#2878b8', bot:'#88c8e8', stars:false, sun:'high' }, |
| | evening: { top:'#1a1040', bot:'#e07840', stars:false, sun:'low' }, |
| | night: { top:'#060610', bot:'#101028', stars:true, sun:'moon' }, |
| | }; |
| | |
| | const GROUND_TINT = { |
| | dawn: { base:'#3a5a2a', shade: 0.7 }, |
| | morning: { base:'#4a7a38', shade: 1.0 }, |
| | afternoon: { base:'#4a7a38', shade: 1.0 }, |
| | evening: { base:'#3a4a28', shade: 0.6 }, |
| | night: { base:'#0e1a0e', shade: 0.25 }, |
| | }; |
| | |
| | |
| | |
| | |
| | let canvas, ctx; |
| | let locations = {}; |
| | let agents = {}; |
| | let agentPositions = {}; |
| | let agentTargets = {}; |
| | let selectedAgentId = null; |
| | let eventLog = []; |
| | let conversationData = { active: [], recent: [] }; |
| | let connected = false; |
| | let hoveredAgent = null; |
| | let currentTimeOfDay = 'morning'; |
| | let currentWeather = 'sunny'; |
| | let animFrame = 0; |
| | let raindrops = []; |
| | let clouds = []; |
| | let stars = []; |
| | let activeTab = 'agents'; |
| | let agentIdxMap = {}; |
| | |
| | |
| | let panX = 0, panY = 0; |
| | let zoom = 1.0; |
| | |
| | let agentPrevLocations = {}; |
| | let agentWaypoints = {}; |
| | let agentFacingRight = {}; |
| | let agentMovingUp = {}; |
| | let locCrowdCount = {}; |
| | let isDragging = false, dragStartX = 0, dragStartY = 0, dragPanStartX = 0, dragPanStartY = 0; |
| | |
| | let rectZoomMode = false, isRectDragging = false, rectStart = null, rectEnd = null; |
| | let _blockNextClick = false; |
| | |
| | |
| | let trees = []; |
| | let streetLamps = []; |
| | |
| | function initParticles() { |
| | for (let i = 0; i < 120; i++) |
| | raindrops.push({x:Math.random(), y:Math.random(), speed:0.012+Math.random()*0.015, len:8+Math.random()*10}); |
| | for (let i = 0; i < 5; i++) |
| | clouds.push({x:Math.random(), y:0.02+Math.random()*0.08, w:60+Math.random()*50, speed:0.00005+Math.random()*0.0001}); |
| | for (let i = 0; i < 70; i++) |
| | stars.push({x:Math.random(), y:Math.random()*HORIZON, size:0.5+Math.random()*1.5, tw:Math.random()*6.28}); |
| | |
| | const treeZones = [ |
| | {cx:0.50, cy:0.19, rx:0.10, ry:0.03, count:8}, |
| | {cx:0.22, cy:0.78, rx:0.06, ry:0.03, count:5}, |
| | {cx:0.50, cy:0.50, rx:0.04, ry:0.03, count:4}, |
| | {cx:0.08, cy:0.78, rx:0.03, ry:0.02, count:3}, |
| | {cx:0.50, cy:0.85, rx:0.15, ry:0.03, count:5}, |
| | ]; |
| | for (const z of treeZones) { |
| | for (let i = 0; i < z.count; i++) { |
| | trees.push({ |
| | x: z.cx + (Math.random()-0.5)*2*z.rx, |
| | y: z.cy + (Math.random()-0.5)*2*z.ry, |
| | size: 6 + Math.random()*5, |
| | type: Math.random() > 0.3 ? 'round' : 'pine', |
| | }); |
| | } |
| | } |
| | |
| | for (let i = 0; i < 8; i++) streetLamps.push({ x: 0.50, y: 0.16 + i*0.10 }); |
| | for (let i = 0; i < 5; i++) streetLamps.push({ x: 0.06 + i*0.22, y: 0.28 }); |
| | for (let i = 0; i < 5; i++) streetLamps.push({ x: 0.06 + i*0.22, y: 0.58 }); |
| | for (let i = 0; i < 4; i++) streetLamps.push({ x: 0.15, y: 0.25 + i*0.18 }); |
| | for (let i = 0; i < 4; i++) streetLamps.push({ x: 0.85, y: 0.25 + i*0.18 }); |
| | } |
| | |
| | |
| | |
| | |
| | function switchTab(tab) { |
| | activeTab = tab; |
| | document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); |
| | document.querySelectorAll('.tab-content').forEach(t => t.classList.toggle('active', t.id === 'tab-' + tab)); |
| | if (tab === 'conversations') fetchConversations(); |
| | } |
| | |
| | |
| | |
| | |
| | function initCanvas() { |
| | canvas = document.getElementById('cityCanvas'); |
| | ctx = canvas.getContext('2d'); |
| | resizeCanvas(); |
| | window.addEventListener('resize', resizeCanvas); |
| | canvas.addEventListener('click', onCanvasClick); |
| | canvas.addEventListener('mousemove', onCanvasMouseMove); |
| | canvas.addEventListener('mousedown', onCanvasDragStart); |
| | canvas.addEventListener('mousemove', onCanvasDrag); |
| | canvas.addEventListener('mouseup', onCanvasDragEnd); |
| | canvas.addEventListener('mouseleave', onCanvasDragEnd); |
| | canvas.addEventListener('wheel', onCanvasWheel, {passive: false}); |
| | initParticles(); |
| | requestAnimationFrame(animate); |
| | } |
| | function resizeCanvas() { |
| | const c = document.getElementById('canvas-container'); |
| | canvas.width = c.clientWidth; |
| | canvas.height = c.clientHeight; |
| | } |
| | |
| | |
| | function worldW() { return canvas.width; } |
| | function worldH() { return canvas.height; } |
| | function maxPanX() { return Math.max(0, canvas.width * (1 - 1 / zoom)); } |
| | function maxPanY() { return Math.max(0, canvas.height * (1 - 1 / zoom)); } |
| | |
| | function onPanSlider() { |
| | const sx = document.getElementById('pan-x'); |
| | const sy = document.getElementById('pan-y'); |
| | panX = (sx.value / 100) * maxPanX(); |
| | panY = (sy.value / 100) * maxPanY(); |
| | } |
| | function syncSliders() { |
| | const sx = document.getElementById('pan-x'); |
| | const sy = document.getElementById('pan-y'); |
| | if (sx) sx.value = maxPanX() > 0 ? (panX / maxPanX()) * 100 : 0; |
| | if (sy) sy.value = maxPanY() > 0 ? (panY / maxPanY()) * 100 : 0; |
| | } |
| | function onCanvasDragStart(e) { |
| | if (e.button !== 0) return; |
| | if (rectZoomMode) { |
| | const r = canvas.getBoundingClientRect(); |
| | rectStart = {x: e.clientX - r.left, y: e.clientY - r.top}; |
| | rectEnd = {...rectStart}; |
| | isRectDragging = true; |
| | return; |
| | } |
| | isDragging = true; |
| | dragStartX = e.clientX; dragStartY = e.clientY; |
| | dragPanStartX = panX; dragPanStartY = panY; |
| | } |
| | function onCanvasDrag(e) { |
| | if (isRectDragging) { |
| | const r = canvas.getBoundingClientRect(); |
| | rectEnd = {x: e.clientX - r.left, y: e.clientY - r.top}; |
| | return; |
| | } |
| | if (!isDragging) return; |
| | const dx = dragStartX - e.clientX, dy = dragStartY - e.clientY; |
| | if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return; |
| | panX = Math.max(0, Math.min(maxPanX(), dragPanStartX + dx / zoom)); |
| | panY = Math.max(0, Math.min(maxPanY(), dragPanStartY + dy / zoom)); |
| | syncSliders(); |
| | } |
| | function onCanvasDragEnd() { |
| | if (isRectDragging) { |
| | isRectDragging = false; |
| | if (rectStart && rectEnd) { |
| | const rw = Math.abs(rectEnd.x - rectStart.x), rh = Math.abs(rectEnd.y - rectStart.y); |
| | if (rw > 10 && rh > 10) { applyRectZoom(); _blockNextClick = true; } |
| | } |
| | rectStart = null; rectEnd = null; |
| | rectZoomMode = false; |
| | const btn = document.getElementById('btn-rect-zoom'); |
| | if (btn) btn.classList.remove('active'); |
| | canvas.style.cursor = 'default'; |
| | return; |
| | } |
| | isDragging = false; |
| | } |
| | |
| | |
| | |
| | |
| | function onCanvasWheel(e) { |
| | e.preventDefault(); |
| | const r = canvas.getBoundingClientRect(); |
| | const sx = e.clientX - r.left, sy = e.clientY - r.top; |
| | zoomAround(e.deltaY < 0 ? 1.15 : 1 / 1.15, sx, sy); |
| | } |
| | function zoomAround(factor, sx, sy) { |
| | const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom * factor)); |
| | if (newZoom === zoom) return; |
| | |
| | const wx = sx / zoom + panX, wy = sy / zoom + panY; |
| | zoom = newZoom; |
| | panX = Math.max(0, Math.min(maxPanX(), wx - sx / zoom)); |
| | panY = Math.max(0, Math.min(maxPanY(), wy - sy / zoom)); |
| | syncSliders(); |
| | } |
| | function zoomBy(factor) { zoomAround(factor, canvas.width / 2, canvas.height / 2); } |
| | function zoomFit() { zoom = 1.0; panX = 0; panY = 0; syncSliders(); } |
| | function applyRectZoom() { |
| | if (!rectStart || !rectEnd) return; |
| | const x1 = Math.min(rectStart.x, rectEnd.x), y1 = Math.min(rectStart.y, rectEnd.y); |
| | const x2 = Math.max(rectStart.x, rectEnd.x), y2 = Math.max(rectStart.y, rectEnd.y); |
| | const rw = x2 - x1, rh = y2 - y1; |
| | if (rw < 10 || rh < 10) return; |
| | |
| | const wx1 = x1 / zoom + panX, wy1 = y1 / zoom + panY; |
| | const wx2 = x2 / zoom + panX, wy2 = y2 / zoom + panY; |
| | const newZoom = Math.min(MAX_ZOOM, Math.min(canvas.width / (wx2 - wx1), canvas.height / (wy2 - wy1))); |
| | zoom = Math.max(MIN_ZOOM, newZoom); |
| | panX = Math.max(0, Math.min(maxPanX(), wx1)); |
| | panY = Math.max(0, Math.min(maxPanY(), wy1)); |
| | syncSliders(); |
| | } |
| | function toggleRectZoom() { |
| | rectZoomMode = !rectZoomMode; |
| | const btn = document.getElementById('btn-rect-zoom'); |
| | if (btn) btn.classList.toggle('active', rectZoomMode); |
| | canvas.style.cursor = rectZoomMode ? 'crosshair' : 'default'; |
| | } |
| | |
| | function animate() { |
| | animFrame++; |
| | for (const [id, target] of Object.entries(agentTargets)) { |
| | if (!agentPositions[id]) { agentPositions[id] = {...target}; continue; } |
| | const p = agentPositions[id]; |
| | const agent = agents[id]; |
| | |
| | |
| | const wps = agentWaypoints[id]; |
| | if (wps && wps.length > 0) { |
| | const d = Math.hypot(p.x - wps[0].x, p.y - wps[0].y); |
| | if (d < 4) { wps.shift(); if (wps.length === 0) delete agentWaypoints[id]; } |
| | } |
| | const dest = (agentWaypoints[id] && agentWaypoints[id].length) ? agentWaypoints[id][0] : target; |
| | |
| | |
| | const isMoving = agent && (agent.state === 'moving'); |
| | const lerpRate = isMoving ? 0.022 : 0.07; |
| | const prevX = p.x, prevY = p.y; |
| | p.x += (dest.x - p.x) * lerpRate; |
| | p.y += (dest.y - p.y) * lerpRate; |
| | |
| | const mdx = p.x - prevX, mdy = p.y - prevY; |
| | if (Math.abs(mdx) > 0.1 || Math.abs(mdy) > 0.1) { |
| | if (Math.abs(mdy) > Math.abs(mdx)) { |
| | agentMovingUp[id] = mdy < 0; |
| | } else { |
| | agentFacingRight[id] = mdx > 0; |
| | agentMovingUp[id] = false; |
| | } |
| | } |
| | } |
| | draw(); |
| | requestAnimationFrame(animate); |
| | } |
| | |
| | |
| | |
| | |
| | function draw() { |
| | if (!ctx) return; |
| | const W = worldW(), H = worldH(); |
| | const cW = canvas.width, cH = canvas.height; |
| | |
| | |
| | const skyCfg = SKY[currentTimeOfDay] || SKY.morning; |
| | ctx.fillStyle = skyCfg.bot; |
| | ctx.fillRect(0, 0, cW, cH); |
| | |
| | |
| | drawSky(cW, cH); |
| | |
| | ctx.save(); |
| | ctx.scale(zoom, zoom); |
| | ctx.translate(-panX, -panY); |
| | |
| | drawGround(W, H); |
| | drawZones(W, H); |
| | drawWeather(W, H); |
| | drawRoads(W, H); |
| | drawSidewalks(W, H); |
| | drawTrees(W, H); |
| | |
| | |
| | const allPositions = getEffectivePositions(); |
| | for (const [id, pos] of Object.entries(allPositions)) { |
| | if (pos.type !== 'street') drawBuilding(id, pos, W, H); |
| | } |
| | |
| | drawStreetLamps(W, H); |
| | |
| | |
| | const byLoc = {}; |
| | for (const [id, a] of Object.entries(agents)) { |
| | const loc = a.location || 'house_elena'; |
| | if (!byLoc[loc]) byLoc[loc] = []; |
| | byLoc[loc].push({id, ...a}); |
| | } |
| | for (const [id, a] of Object.entries(agents)) { |
| | computeAgentTarget(id, a, getAgentIdx(id), byLoc, W, H); |
| | } |
| | |
| | drawCoupleLines(W, H); |
| | drawConversationBubbles(W, H); |
| | |
| | for (const [id, a] of Object.entries(agents)) { |
| | drawPerson(id, a, getAgentIdx(id), W, H); |
| | } |
| | |
| | if (currentWeather === 'foggy') { |
| | ctx.fillStyle = `rgba(180,190,200,${0.30 + Math.sin(animFrame*0.015)*0.08})`; |
| | ctx.fillRect(0, 0, W, H); |
| | } |
| | |
| | ctx.restore(); |
| | |
| | |
| | if (isRectDragging && rectStart && rectEnd) { |
| | const x1 = Math.min(rectStart.x, rectEnd.x), y1 = Math.min(rectStart.y, rectEnd.y); |
| | const x2 = Math.max(rectStart.x, rectEnd.x), y2 = Math.max(rectStart.y, rectEnd.y); |
| | ctx.fillStyle = 'rgba(78,204,163,0.08)'; |
| | ctx.fillRect(x1, y1, x2 - x1, y2 - y1); |
| | ctx.strokeStyle = 'rgba(78,204,163,0.9)'; |
| | ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]); |
| | ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); |
| | ctx.setLineDash([]); |
| | } |
| | |
| | |
| | if (zoom !== 1.0 || rectZoomMode) { |
| | ctx.font = 'bold 10px Segoe UI'; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; |
| | const label = rectZoomMode ? '⬚ draw rect to zoom' : `${zoom.toFixed(1)}x`; |
| | const tw = ctx.measureText(label).width + 14; |
| | ctx.fillStyle = 'rgba(15,52,96,0.75)'; |
| | ctx.fillRect(cW - tw - 4, cH - 22, tw + 4, 18); |
| | ctx.fillStyle = rectZoomMode ? '#f0c040' : '#4ecca3'; |
| | ctx.fillText(label, cW - 6, cH - 6); |
| | } |
| | } |
| | |
| | |
| | let _genPosCache = {}; |
| | function getEffectivePositions() { |
| | const result = {...LOCATION_POSITIONS}; |
| | for (const locId of Object.keys(locations)) { |
| | if (!result[locId]) { |
| | if (!_genPosCache[locId]) { |
| | let h = 0; |
| | for (let i = 0; i < locId.length; i++) h = ((h << 5) - h + locId.charCodeAt(i)) | 0; |
| | const genMatch = locId.match(/house_gen_(\d+)/); |
| | if (genMatch) { |
| | |
| | const slotIdx = (parseInt(genMatch[1]) - 1) % GEN_HOUSE_SLOTS.length; |
| | const sl = GEN_HOUSE_SLOTS[slotIdx]; |
| | _genPosCache[locId] = { |
| | x: sl.x, |
| | y: sl.y, |
| | type: 'house', |
| | label: (locations[locId]?.name || locId).slice(0, 14), |
| | }; |
| | } else { |
| | |
| | _genPosCache[locId] = { |
| | x: 0.05 + ((h >>> 0) % 18) / 18 * 0.90, |
| | y: 0.05 + ((h >>> 4) % 16) / 16 * 0.90, |
| | type: 'house', |
| | label: (locations[locId]?.name || locId).slice(0, 14), |
| | }; |
| | } |
| | } |
| | result[locId] = _genPosCache[locId]; |
| | } |
| | } |
| | return result; |
| | } |
| | |
| | function getAgentIdx(id) { |
| | if (!(id in agentIdxMap)) agentIdxMap[id] = Object.keys(agentIdxMap).length; |
| | return agentIdxMap[id]; |
| | } |
| | |
| | |
| | |
| | |
| | function drawSky(W, H) { |
| | |
| | const s = SKY[currentTimeOfDay] || SKY.morning; |
| | const hLine = H * HORIZON; |
| | const grad = ctx.createLinearGradient(0, 0, 0, hLine); |
| | grad.addColorStop(0, s.top); |
| | grad.addColorStop(1, s.bot); |
| | ctx.fillStyle = grad; |
| | ctx.fillRect(0, 0, W, hLine); |
| | |
| | if (s.stars) { |
| | for (const st of stars) { |
| | const tw = 0.3 + 0.7 * Math.abs(Math.sin(animFrame * 0.025 + st.tw)); |
| | ctx.fillStyle = `rgba(255,255,240,${tw})`; |
| | ctx.beginPath(); ctx.arc(st.x*W, st.y*H, st.size, 0, 6.28); ctx.fill(); |
| | } |
| | } |
| | if (s.sun === 'moon') drawMoon(W*0.82, hLine*0.35, 18); |
| | else if (s.sun === 'high') drawSun(W*0.78, hLine*0.25, 16); |
| | else if (s.sun === 'mid') drawSun(W*0.80, hLine*0.45, 16); |
| | else if (s.sun === 'low') drawSun(W*0.82, hLine*0.7, 16); |
| | |
| | |
| | drawMountains(W, hLine); |
| | } |
| | |
| | function drawMountains(W, hLine) { |
| | const isDark = currentTimeOfDay === 'night' || currentTimeOfDay === 'evening'; |
| | const isDawn = currentTimeOfDay === 'dawn'; |
| | |
| | |
| | const farPeaks = [ |
| | {x:0.00,h:0.55},{x:0.08,h:0.85},{x:0.18,h:0.65},{x:0.28,h:0.95}, |
| | {x:0.38,h:0.70},{x:0.48,h:1.0},{x:0.58,h:0.75},{x:0.68,h:0.90}, |
| | {x:0.78,h:0.80},{x:0.88,h:0.70},{x:0.98,h:0.60},{x:1.05,h:0.50}, |
| | ]; |
| | ctx.fillStyle = isDark ? '#0a0e18' : (isDawn ? '#4a3858' : '#5a6878'); |
| | ctx.beginPath(); |
| | ctx.moveTo(0, hLine); |
| | for (const p of farPeaks) { |
| | ctx.lineTo(p.x * W, hLine - p.h * hLine * 0.6); |
| | } |
| | ctx.lineTo(W, hLine); |
| | ctx.closePath(); |
| | ctx.fill(); |
| | |
| | |
| | const nearPeaks = [ |
| | {x:-0.02,h:0.30},{x:0.05,h:0.55},{x:0.14,h:0.40},{x:0.22,h:0.65}, |
| | {x:0.32,h:0.45},{x:0.42,h:0.60},{x:0.52,h:0.50},{x:0.60,h:0.70}, |
| | {x:0.70,h:0.55},{x:0.80,h:0.50},{x:0.90,h:0.62},{x:1.02,h:0.35}, |
| | ]; |
| | ctx.fillStyle = isDark ? '#141828' : (isDawn ? '#5a4868' : '#6a7888'); |
| | ctx.beginPath(); |
| | ctx.moveTo(0, hLine); |
| | for (const p of nearPeaks) { |
| | ctx.lineTo(p.x * W, hLine - p.h * hLine * 0.45); |
| | } |
| | ctx.lineTo(W, hLine); |
| | ctx.closePath(); |
| | ctx.fill(); |
| | |
| | |
| | if (!isDark) { |
| | ctx.fillStyle = isDawn ? 'rgba(220,200,220,0.5)' : 'rgba(240,245,255,0.6)'; |
| | for (const p of farPeaks) { |
| | if (p.h > 0.75) { |
| | const px = p.x * W; |
| | const py = hLine - p.h * hLine * 0.6; |
| | ctx.beginPath(); |
| | ctx.moveTo(px, py); |
| | ctx.lineTo(px - 12, py + hLine * 0.06); |
| | ctx.lineTo(px + 12, py + hLine * 0.06); |
| | ctx.closePath(); |
| | ctx.fill(); |
| | } |
| | } |
| | } |
| | |
| | |
| | const hazeGrad = ctx.createLinearGradient(0, hLine - 15, 0, hLine + 5); |
| | hazeGrad.addColorStop(0, 'rgba(0,0,0,0)'); |
| | hazeGrad.addColorStop(1, isDark ? 'rgba(10,15,25,0.5)' : (isDawn ? 'rgba(180,140,120,0.3)' : 'rgba(140,160,180,0.25)')); |
| | ctx.fillStyle = hazeGrad; |
| | ctx.fillRect(0, hLine - 15, W, 20); |
| | } |
| | |
| | function drawSun(x, y, r) { |
| | const glow = ctx.createRadialGradient(x, y, r*0.5, x, y, r*4); |
| | glow.addColorStop(0, 'rgba(255,220,100,0.35)'); |
| | glow.addColorStop(1, 'rgba(255,220,100,0)'); |
| | ctx.fillStyle = glow; ctx.fillRect(x-r*4, y-r*4, r*8, r*8); |
| | ctx.save(); ctx.translate(x, y); ctx.rotate(animFrame*0.005); |
| | for (let i = 0; i < 8; i++) { ctx.rotate(Math.PI/4); ctx.fillStyle='rgba(255,220,100,0.25)'; ctx.fillRect(-1.5,r+3,3,8); } |
| | ctx.restore(); |
| | ctx.fillStyle='#ffe066'; ctx.beginPath(); ctx.arc(x,y,r,0,6.28); ctx.fill(); |
| | } |
| | |
| | function drawMoon(x, y, r) { |
| | const glow = ctx.createRadialGradient(x,y,r*0.5,x,y,r*3); |
| | glow.addColorStop(0,'rgba(200,210,240,0.15)'); |
| | glow.addColorStop(1,'rgba(200,210,240,0)'); |
| | ctx.fillStyle=glow; ctx.fillRect(x-r*3,y-r*3,r*6,r*6); |
| | ctx.fillStyle='#d8dff0'; ctx.beginPath(); ctx.arc(x,y,r,0,6.28); ctx.fill(); |
| | ctx.fillStyle=SKY.night.top; ctx.beginPath(); ctx.arc(x+7,y-2,r*0.85,0,6.28); ctx.fill(); |
| | } |
| | |
| | |
| | |
| | |
| | function drawGround(W, H) { |
| | const hLine = (canvas.height * HORIZON); |
| | const gt = GROUND_TINT[currentTimeOfDay] || GROUND_TINT.morning; |
| | const bc = hexToRgb(gt.base); |
| | const isDark = currentTimeOfDay === 'night' || currentTimeOfDay === 'evening'; |
| | const isDawn = currentTimeOfDay === 'dawn'; |
| | |
| | |
| | const grad = ctx.createLinearGradient(0, hLine, 0, H); |
| | grad.addColorStop(0, `rgb(${bc.r+28},${bc.g+40},${bc.b+12})`); |
| | grad.addColorStop(0.4, `rgb(${bc.r+10},${bc.g+20},${bc.b+5})`); |
| | grad.addColorStop(1, `rgb(${Math.max(0,bc.r-20)},${Math.max(0,bc.g-20)},${Math.max(0,bc.b-14)})`); |
| | ctx.fillStyle = grad; |
| | ctx.fillRect(0, hLine, W, H - hLine); |
| | |
| | |
| | if (!isDark) { |
| | const bandY = hLine + (H - hLine) * 0.5; |
| | const bandGrad = ctx.createLinearGradient(0, bandY - 12, 0, bandY + 12); |
| | bandGrad.addColorStop(0, 'rgba(0,0,0,0)'); |
| | bandGrad.addColorStop(0.5, isDawn ? 'rgba(180,120,60,0.06)' : 'rgba(80,130,40,0.06)'); |
| | bandGrad.addColorStop(1, 'rgba(0,0,0,0)'); |
| | ctx.fillStyle = bandGrad; |
| | ctx.fillRect(0, bandY - 12, W, 24); |
| | } |
| | |
| | |
| | if (currentWeather === 'rainy' || currentWeather === 'stormy') { |
| | ctx.fillStyle = `rgba(40, 50, 70, ${currentWeather === 'stormy' ? 0.35 : 0.2})`; |
| | ctx.fillRect(0, hLine, W, H - hLine); |
| | } |
| | |
| | |
| | ctx.fillStyle = `rgba(${60+bc.r*0.3},${90+bc.g*0.3},${40+bc.b*0.2}, 0.14)`; |
| | for (let i = 0; i < 120; i++) { |
| | const gx = (i * 37 + 13) % W; |
| | const gy = hLine + 10 + ((i * 53 + 7) % (H - hLine - 15)); |
| | ctx.fillRect(gx, gy, 2, 3); |
| | } |
| | |
| | |
| | const horizGrad = ctx.createLinearGradient(0, hLine - 4, 0, hLine + 8); |
| | const s = SKY[currentTimeOfDay] || SKY.morning; |
| | horizGrad.addColorStop(0, s.bot); |
| | horizGrad.addColorStop(1, gt.base); |
| | ctx.fillStyle = horizGrad; |
| | ctx.fillRect(0, hLine - 4, W, 12); |
| | } |
| | |
| | function hexToRgb(hex) { |
| | const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16); |
| | return {r,g,b}; |
| | } |
| | |
| | |
| | |
| | |
| | function drawZones(W, H) { |
| | const isDark = currentTimeOfDay === 'night' || currentTimeOfDay === 'evening'; |
| | const isDawn = currentTimeOfDay === 'dawn'; |
| | |
| | const zones = [ |
| | |
| | { cx: 0.50, cy: 0.19, rx: 0.11, ry: 0.055, c: isDark ? '#1a4a18' : (isDawn ? '#4a7a30' : '#7ecf50'), a: isDark ? 0.22 : 0.35 }, |
| | |
| | { cx: 0.22, cy: 0.78, rx: 0.07, ry: 0.055, c: isDark ? '#1a3818' : '#5ab838', a: isDark ? 0.20 : 0.30 }, |
| | |
| | { cx: 0.50, cy: 0.50, rx: 0.07, ry: 0.045, c: isDark ? '#3a3010' : '#e8c86a', a: isDark ? 0.18 : 0.32 }, |
| | |
| | { cx: 0.48, cy: 0.42, rx: 0.32, ry: 0.075, c: isDark ? '#2a1808' : '#e8b880', a: isDark ? 0.15 : 0.20 }, |
| | |
| | { cx: 0.48, cy: 0.20, rx: 0.44, ry: 0.060, c: isDark ? '#28201a' : '#eedcb0', a: isDark ? 0.13 : 0.22 }, |
| | |
| | { cx: 0.48, cy: 0.66, rx: 0.44, ry: 0.065, c: isDark ? '#28201a' : '#e8d8a0', a: isDark ? 0.13 : 0.22 }, |
| | |
| | { cx: 0.66, cy: 0.34, rx: 0.22, ry: 0.075, c: isDark ? '#182030' : '#b0cce8', a: isDark ? 0.18 : 0.24 }, |
| | |
| | { cx: 0.91, cy: 0.63, rx: 0.07, ry: 0.08, c: isDark ? '#201408' : '#c89860', a: isDark ? 0.20 : 0.28 }, |
| | |
| | { cx: 0.91, cy: 0.50, rx: 0.07, ry: 0.055, c: isDark ? '#182828' : '#b8e8e0', a: isDark ? 0.18 : 0.26 }, |
| | |
| | { cx: 0.08, cy: 0.50, rx: 0.07, ry: 0.055, c: isDark ? '#28200a' : '#f0e890', a: isDark ? 0.16 : 0.26 }, |
| | |
| | { cx: 0.08, cy: 0.78, rx: 0.06, ry: 0.045, c: isDark ? '#1a2030' : '#d0c8e8', a: isDark ? 0.18 : 0.28 }, |
| | ]; |
| | |
| | for (const z of zones) { |
| | 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)); |
| | grd.addColorStop(0, z.c); |
| | grd.addColorStop(1, 'rgba(0,0,0,0)'); |
| | ctx.globalAlpha = z.a; |
| | ctx.fillStyle = grd; |
| | ctx.beginPath(); |
| | ctx.ellipse(z.cx*W, z.cy*H, z.rx*W, z.ry*H, 0, 0, 6.28); |
| | ctx.fill(); |
| | } |
| | ctx.globalAlpha = 1.0; |
| | } |
| | |
| | |
| | |
| | |
| | function drawWeather(W, H) { |
| | const w = currentWeather; |
| | if (w==='cloudy'||w==='rainy'||w==='stormy') { |
| | const op = w==='stormy'?0.65:(w==='rainy'?0.45:0.30); |
| | for (const c of clouds) { |
| | c.x+=c.speed; if(c.x>1.15) c.x=-0.15; |
| | drawCloud(c.x*W, c.y*H, c.w, op); |
| | } |
| | } |
| | if (w==='rainy'||w==='stormy') { |
| | ctx.strokeStyle = w==='stormy'?'rgba(180,200,255,0.5)':'rgba(150,180,220,0.35)'; |
| | ctx.lineWidth=1; |
| | for (const r of raindrops) { |
| | r.y+=r.speed; if(r.y>1){r.y=-0.03;r.x=Math.random();} |
| | const rx=r.x*W, ry=r.y*H; |
| | ctx.beginPath(); ctx.moveTo(rx,ry); ctx.lineTo(rx-2,ry+r.len); ctx.stroke(); |
| | } |
| | } |
| | if (w==='stormy'&&animFrame%120<3) { |
| | ctx.fillStyle=`rgba(255,255,255,${0.12+Math.random()*0.08})`; |
| | ctx.fillRect(0,0,W,H); |
| | } |
| | } |
| | |
| | function drawCloud(x,y,w,op) { |
| | ctx.fillStyle=`rgba(200,210,220,${op})`; |
| | const h=w*0.32; |
| | ctx.beginPath(); ctx.ellipse(x,y,w*0.5,h*0.6,0,0,6.28); ctx.fill(); |
| | ctx.beginPath(); ctx.ellipse(x-w*0.25,y+h*0.15,w*0.3,h*0.45,0,0,6.28); ctx.fill(); |
| | ctx.beginPath(); ctx.ellipse(x+w*0.28,y+h*0.1,w*0.28,h*0.4,0,0,6.28); ctx.fill(); |
| | } |
| | |
| | |
| | |
| | |
| | function drawRoads(W, H) { |
| | const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening'; |
| | const asphalt = isDark ? '#2a2820' : '#6b6560'; |
| | const asphaltEdge = isDark ? '#1a1815' : '#555048'; |
| | |
| | for (const road of ROADS) { |
| | const x1=road.x1*W, y1=road.y1*H, x2=road.x2*W, y2=road.y2*H; |
| | const w = road.width || 10; |
| | const dx = x2-x1, dy = y2-y1; |
| | const len = Math.hypot(dx, dy); |
| | const nx = -dy/len * w/2, ny = dx/len * w/2; |
| | |
| | |
| | ctx.fillStyle = asphalt; |
| | ctx.beginPath(); |
| | ctx.moveTo(x1+nx, y1+ny); |
| | ctx.lineTo(x2+nx, y2+ny); |
| | ctx.lineTo(x2-nx, y2-ny); |
| | ctx.lineTo(x1-nx, y1-ny); |
| | ctx.closePath(); |
| | ctx.fill(); |
| | |
| | |
| | ctx.strokeStyle = asphaltEdge; |
| | ctx.lineWidth = 1; |
| | ctx.beginPath(); ctx.moveTo(x1+nx,y1+ny); ctx.lineTo(x2+nx,y2+ny); ctx.stroke(); |
| | ctx.beginPath(); ctx.moveTo(x1-nx,y1-ny); ctx.lineTo(x2-nx,y2-ny); ctx.stroke(); |
| | |
| | |
| | if (w >= 12) { |
| | ctx.strokeStyle = isDark ? 'rgba(200,180,80,0.25)' : 'rgba(255,220,100,0.6)'; |
| | ctx.lineWidth = 1.5; |
| | ctx.setLineDash([8, 10]); |
| | ctx.beginPath(); |
| | ctx.moveTo((x1+x2)/2 - (x2-x1)*0.48 + (x2-x1)*0.02, (y1+y2)/2 - (y2-y1)*0.48 + (y2-y1)*0.02); |
| | ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); |
| | ctx.stroke(); |
| | ctx.setLineDash([]); |
| | } |
| | } |
| | } |
| | |
| | |
| | function drawSidewalks(W, H) { |
| | const isDark = currentTimeOfDay==='night'; |
| | ctx.fillStyle = isDark ? 'rgba(90,85,75,0.3)' : 'rgba(180,175,160,0.35)'; |
| | |
| | for (const road of ROADS) { |
| | const x1=road.x1*W, y1=road.y1*H, x2=road.x2*W, y2=road.y2*H; |
| | const w = (road.width || 10) + 6; |
| | const dx = x2-x1, dy = y2-y1; |
| | const len = Math.hypot(dx, dy); |
| | const nx = -dy/len * w/2, ny = dx/len * w/2; |
| | const sw = 3; |
| | |
| | |
| | ctx.fillRect( |
| | Math.min(x1+nx, x2+nx) - sw/2, |
| | Math.min(y1+ny, y2+ny) - sw/2, |
| | Math.abs(x2+nx - x1-nx) + sw, |
| | Math.abs(y2+ny - y1-ny) + sw |
| | ); |
| | } |
| | } |
| | |
| | |
| | |
| | |
| | function drawTrees(W, H) { |
| | const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening'; |
| | for (const t of trees) { |
| | const tx = t.x * W, ty = t.y * H; |
| | |
| | ctx.fillStyle = isDark ? '#3a2a15' : '#6b4226'; |
| | ctx.fillRect(tx-2, ty-2, 4, 10); |
| | |
| | if (t.type === 'pine') { |
| | ctx.fillStyle = isDark ? '#1a3a18' : '#2a7a28'; |
| | ctx.beginPath(); ctx.moveTo(tx, ty - t.size - 6); ctx.lineTo(tx - t.size*0.6, ty); ctx.lineTo(tx + t.size*0.6, ty); ctx.closePath(); ctx.fill(); |
| | ctx.fillStyle = isDark ? '#1e4a1c' : '#3a8a30'; |
| | ctx.beginPath(); ctx.moveTo(tx, ty - t.size - 2); ctx.lineTo(tx - t.size*0.4, ty - 3); ctx.lineTo(tx + t.size*0.4, ty - 3); ctx.closePath(); ctx.fill(); |
| | } else { |
| | ctx.fillStyle = isDark ? '#1a4018' : '#3a8a30'; |
| | ctx.beginPath(); ctx.arc(tx, ty - t.size*0.5, t.size*0.7, 0, 6.28); ctx.fill(); |
| | ctx.fillStyle = isDark ? '#2a5028' : '#55aa45'; |
| | ctx.beginPath(); ctx.arc(tx - 2, ty - t.size*0.6, t.size*0.35, 0, 6.28); ctx.fill(); |
| | } |
| | } |
| | } |
| | |
| | |
| | |
| | |
| | function drawStreetLamps(W, H) { |
| | const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening'; |
| | for (const l of streetLamps) { |
| | const lx = l.x*W + 12, ly = l.y*H; |
| | ctx.fillStyle = '#555'; ctx.fillRect(lx-1, ly-14, 2, 14); |
| | ctx.fillStyle = '#666'; ctx.fillRect(lx-3, ly-16, 6, 3); |
| | if (isDark) { |
| | const glow = ctx.createRadialGradient(lx, ly-14, 2, lx, ly-14, 30); |
| | glow.addColorStop(0, 'rgba(255,220,130,0.25)'); |
| | glow.addColorStop(1, 'rgba(255,220,130,0)'); |
| | ctx.fillStyle = glow; |
| | ctx.fillRect(lx-30, ly-44, 60, 60); |
| | ctx.fillStyle = 'rgba(255,220,130,0.8)'; |
| | ctx.beginPath(); ctx.arc(lx, ly-14, 2, 0, 6.28); ctx.fill(); |
| | } |
| | } |
| | } |
| | |
| | |
| | |
| | |
| | function drawBuilding(id, pos, W, H) { |
| | const x = pos.x*W, y = pos.y*H; |
| | const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening'; |
| | const loc = locations[id] || {}; |
| | const occ = (loc.occupants || []).length; |
| | |
| | if (pos.type === 'house') drawHouse(x, y, isDark, id); |
| | else if (pos.type === 'shop') drawShop(x, y, isDark); |
| | else if (pos.type === 'office') drawOffice(x, y, isDark); |
| | else if (pos.type === 'tower') drawTower(x, y, isDark); |
| | else if (pos.type === 'park') drawPark(x, y, isDark); |
| | else if (pos.type === 'public') drawPublicBuilding(x, y, isDark); |
| | else if (pos.type === 'factory') drawFactory(x, y, isDark); |
| | else if (pos.type === 'school') drawSchool(x, y, isDark); |
| | else if (pos.type === 'hospital') drawHospital(x, y, isDark); |
| | else if (pos.type === 'church') drawChurch(x, y, isDark); |
| | else if (pos.type === 'cinema') drawCinema(x, y, isDark); |
| | else if (pos.type === 'apartment') drawApartment(x, y, isDark); |
| | else if (pos.type === 'square') drawSquare(x, y, isDark); |
| | else if (pos.type === 'sports') drawSportsField(x, y, isDark); |
| | |
| | |
| | if (pos.type !== 'park' && pos.type !== 'square' && pos.type !== 'sports') { |
| | const label = pos.label || id; |
| | const short = label.length > 16 ? label.slice(0, 14) + '..' : label; |
| | ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; |
| | const ly = pos.type === 'house' ? y + 18 : (pos.type === 'tower' ? y + 35 : y + 25); |
| | ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(short, x+1, ly+1); |
| | ctx.fillStyle = isDark ? '#a0a8c0' : '#fff'; ctx.fillText(short, x, ly); |
| | } |
| | |
| | |
| | if (occ > 0) { |
| | const byOff = pos.type === 'house' ? 14 : (pos.type === 'tower' ? 32 : (pos.type === 'church' ? 40 : 18)); |
| | const bx = x + (pos.type === 'house' ? 22 : 28), by = y - byOff; |
| | ctx.fillStyle = '#e94560'; ctx.beginPath(); ctx.arc(bx, by, 8, 0, 6.28); ctx.fill(); |
| | ctx.fillStyle = '#fff'; ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; |
| | ctx.fillText(occ.toString(), bx, by); |
| | } |
| | } |
| | |
| | |
| | const ISO_DX = 6; |
| | const ISO_DY = 4; |
| | |
| | function draw25DBox(x, y, w, h, frontColor, sideColor, topColor, dk) { |
| | |
| | ctx.fillStyle = dk ? dim(frontColor, 0.4) : frontColor; |
| | ctx.fillRect(x - w/2, y - h, w, h); |
| | |
| | ctx.fillStyle = dk ? dim(sideColor, 0.35) : sideColor; |
| | ctx.beginPath(); |
| | ctx.moveTo(x + w/2, y - h); |
| | ctx.lineTo(x + w/2 + ISO_DX, y - h - ISO_DY); |
| | ctx.lineTo(x + w/2 + ISO_DX, y - ISO_DY); |
| | ctx.lineTo(x + w/2, y); |
| | ctx.closePath(); ctx.fill(); |
| | |
| | ctx.fillStyle = dk ? dim(topColor, 0.45) : topColor; |
| | ctx.beginPath(); |
| | ctx.moveTo(x - w/2, y - h); |
| | ctx.lineTo(x - w/2 + ISO_DX, y - h - ISO_DY); |
| | ctx.lineTo(x + w/2 + ISO_DX, y - h - ISO_DY); |
| | ctx.lineTo(x + w/2, y - h); |
| | ctx.closePath(); ctx.fill(); |
| | } |
| | |
| | function drawHouse(x, y, dk, id) { |
| | const w = 36, h = 24; |
| | const hues = [ |
| | {wall:'#c8a882', side:'#a88862', roof:'#8b4513', door:'#6b3410'}, |
| | {wall:'#a0b8a0', side:'#809880', roof:'#4a6a4a', door:'#3a4a3a'}, |
| | {wall:'#b8a0a0', side:'#988080', roof:'#7a3a3a', door:'#5a2a2a'}, |
| | {wall:'#a0a8c0', side:'#8088a0', roof:'#4a5a7a', door:'#3a4a6a'}, |
| | {wall:'#c8b878', side:'#a89858', roof:'#8a7a40', door:'#6a5a30'}, |
| | ]; |
| | const allPos = getEffectivePositions(); |
| | const idx = Object.keys(allPos).indexOf(id) % hues.length; |
| | const c = hues[idx]; |
| | |
| | const baseY = y + h/2; |
| | |
| | draw25DBox(x, baseY, w, h, c.wall, c.side, c.roof, dk); |
| | |
| | |
| | ctx.fillStyle = dk ? dim(c.roof, 0.4) : c.roof; |
| | ctx.beginPath(); |
| | ctx.moveTo(x - w/2 - 3, baseY - h); |
| | ctx.lineTo(x, baseY - h - 14); |
| | ctx.lineTo(x + w/2 + 3, baseY - h); |
| | ctx.closePath(); ctx.fill(); |
| | |
| | ctx.fillStyle = dk ? dim(c.roof, 0.3) : dim(c.roof, 0.8); |
| | ctx.beginPath(); |
| | ctx.moveTo(x + w/2 + 3, baseY - h); |
| | ctx.lineTo(x, baseY - h - 14); |
| | ctx.lineTo(x + ISO_DX, baseY - h - 14 - ISO_DY); |
| | ctx.lineTo(x + w/2 + 3 + ISO_DX, baseY - h - ISO_DY); |
| | ctx.closePath(); ctx.fill(); |
| | |
| | |
| | ctx.fillStyle = dk ? dim(c.door, 0.4) : c.door; |
| | ctx.fillRect(x - 3, baseY - 10, 6, 10); |
| | ctx.fillStyle = dk ? '#aa9060' : '#d4b070'; |
| | ctx.beginPath(); ctx.arc(x + 2, baseY - 4, 1, 0, 6.28); ctx.fill(); |
| | |
| | |
| | const wc = dk ? 'rgba(255,210,100,0.75)' : 'rgba(180,220,255,0.55)'; |
| | ctx.fillStyle = wc; |
| | ctx.fillRect(x - w/2 + 4, baseY - h + 5, 8, 6); |
| | ctx.fillRect(x + w/2 - 12, baseY - h + 5, 8, 6); |
| | ctx.strokeStyle = dk ? 'rgba(100,80,50,0.4)' : 'rgba(80,70,60,0.3)'; |
| | ctx.lineWidth = 0.5; |
| | ctx.strokeRect(x - w/2 + 4, baseY - h + 5, 8, 6); |
| | ctx.strokeRect(x + w/2 - 12, baseY - h + 5, 8, 6); |
| | |
| | |
| | ctx.fillStyle = dk ? dim(c.roof, 0.35) : dim(c.roof, 0.8); |
| | ctx.fillRect(x + 8, baseY - h - 10, 5, 8); |
| | } |
| | |
| | function drawShop(x, y, dk) { |
| | const w = 48, h = 32; |
| | const baseY = y + h/2; |
| | draw25DBox(x, baseY, w, h, '#d4c4a8', '#b4a488', '#e0d4c0', dk); |
| | |
| | |
| | const awningColors = ['#c44', '#4a8', '#48a', '#a84']; |
| | const ci = Math.floor(x*7 + y*3) % awningColors.length; |
| | const ac = awningColors[ci]; |
| | ctx.fillStyle = dk ? dim(ac, 0.35) : ac; |
| | ctx.beginPath(); |
| | ctx.moveTo(x-w/2-3, baseY-h); |
| | ctx.lineTo(x-w/2-6, baseY-h+12); |
| | ctx.lineTo(x+w/2+6, baseY-h+12); |
| | ctx.lineTo(x+w/2+3, baseY-h); |
| | ctx.closePath(); ctx.fill(); |
| | |
| | ctx.fillStyle = dk ? dim(ac, 0.25) : dim(ac, 0.7); |
| | ctx.beginPath(); |
| | ctx.moveTo(x+w/2+3, baseY-h); |
| | ctx.lineTo(x+w/2+6, baseY-h+12); |
| | ctx.lineTo(x+w/2+6+ISO_DX, baseY-h+12-ISO_DY); |
| | ctx.lineTo(x+w/2+3+ISO_DX, baseY-h-ISO_DY); |
| | ctx.closePath(); ctx.fill(); |
| | |
| | |
| | ctx.strokeStyle = dk ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.25)'; |
| | ctx.lineWidth = 1; |
| | for (let i = 0; i < 5; i++) { |
| | const sx = x-w/2 + i*(w/4); |
| | ctx.beginPath(); ctx.moveTo(sx, baseY-h); ctx.lineTo(sx-1.5, baseY-h+12); ctx.stroke(); |
| | } |
| | |
| | ctx.fillStyle = dk ? '#2a2520' : '#5a4a3a'; |
| | ctx.fillRect(x-4, baseY-12, 8, 12); |
| | |
| | const wc = dk ? 'rgba(255,210,100,0.65)' : 'rgba(200,230,255,0.55)'; |
| | ctx.fillStyle = wc; |
| | ctx.fillRect(x-w/2+4, baseY-h+14, w/2-10, 10); |
| | ctx.fillRect(x+5, baseY-h+14, w/2-10, 10); |
| | } |
| | |
| | function drawOffice(x, y, dk) { |
| | const w = 50, h = 40; |
| | const baseY = y + h/2; |
| | draw25DBox(x, baseY, w, h, '#8090a8', '#607088', '#90a0b8', dk); |
| | |
| | ctx.fillStyle = dk ? '#1a1a25' : '#4a5a6a'; |
| | ctx.fillRect(x-4, baseY-12, 8, 12); |
| | |
| | const wc = dk ? 'rgba(255,210,100,0.6)' : 'rgba(180,220,255,0.5)'; |
| | ctx.fillStyle = wc; |
| | for (let r = 0; r < 3; r++) |
| | for (let c = 0; c < 3; c++) |
| | ctx.fillRect(x-w/2+6+c*15, baseY-h+5+r*10, 9, 6); |
| | |
| | const swc = dk ? 'rgba(255,200,80,0.4)' : 'rgba(160,200,240,0.35)'; |
| | ctx.fillStyle = swc; |
| | for (let r = 0; r < 3; r++) { |
| | const wy = baseY - h + 6 + r*10; |
| | ctx.beginPath(); |
| | ctx.moveTo(x+w/2+1, wy); |
| | ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY); |
| | ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY+6); |
| | ctx.lineTo(x+w/2+1, wy+6); |
| | ctx.closePath(); ctx.fill(); |
| | } |
| | } |
| | |
| | function drawPublicBuilding(x, y, dk) { |
| | const w = 46, h = 34; |
| | const baseY = y + h/2; |
| | draw25DBox(x, baseY, w, h, '#7a9a6a', '#5a7a4a', '#8aaa7a', dk); |
| | ctx.fillStyle = dk ? '#1a2018' : '#4a6a3a'; |
| | ctx.fillRect(x-4, baseY-12, 8, 12); |
| | const wc = dk ? 'rgba(255,210,100,0.55)' : 'rgba(200,240,200,0.5)'; |
| | ctx.fillStyle = wc; |
| | for (let c = 0; c < 4; c++) ctx.fillRect(x-w/2+4+c*11, baseY-h+6, 8, 8); |
| | for (let c = 0; c < 4; c++) ctx.fillRect(x-w/2+4+c*11, baseY-h+18, 8, 8); |
| | } |
| | |
| | function drawPark(x, y, dk) { |
| | |
| | ctx.fillStyle = dk ? '#1a3018' : '#4a9040'; |
| | ctx.beginPath(); ctx.ellipse(x, y, 55, 25, 0, 0, 6.28); ctx.fill(); |
| | |
| | ctx.fillStyle = dk ? '#122810' : '#3a7030'; |
| | ctx.beginPath(); ctx.ellipse(x, y+3, 55, 25, 0, 0, Math.PI); ctx.fill(); |
| | ctx.strokeStyle = dk ? '#2a4a25' : '#6ab850'; |
| | ctx.lineWidth = 1.5; |
| | ctx.beginPath(); ctx.ellipse(x, y, 55, 25, 0, 0, 6.28); ctx.stroke(); |
| | |
| | ctx.strokeStyle = dk ? '#2a2820' : '#c8c0a8'; |
| | ctx.lineWidth = 3; ctx.setLineDash([6,4]); |
| | ctx.beginPath(); ctx.ellipse(x, y, 32, 14, 0, 0, 6.28); ctx.stroke(); |
| | ctx.setLineDash([]); |
| | |
| | ctx.fillStyle = dk ? '#1a2a3a' : '#5a9ac0'; |
| | ctx.beginPath(); ctx.ellipse(x+15, y+4, 12, 6, 0.2, 0, 6.28); ctx.fill(); |
| | ctx.strokeStyle = dk ? '#2a3a4a' : '#80b0d0'; ctx.lineWidth = 1; ctx.stroke(); |
| | |
| | if (!dk) { |
| | ctx.fillStyle = `rgba(255,255,255,${0.15+Math.sin(animFrame*0.05)*0.1})`; |
| | ctx.beginPath(); ctx.ellipse(x+17, y+2, 4, 2, 0.3, 0, 6.28); ctx.fill(); |
| | } |
| | |
| | ctx.fillStyle = dk ? '#3a2a15' : '#7a5a30'; |
| | ctx.fillRect(x-22, y+7, 14, 3); |
| | ctx.fillStyle = dk ? '#2a1a08' : '#5a3a18'; |
| | ctx.fillRect(x-22, y+10, 14, 2); |
| | |
| | for (let i = -1; i <= 1; i++) { |
| | const tx = x + i*22; |
| | ctx.fillStyle = dk ? '#3a2a15' : '#6b4226'; ctx.fillRect(tx-2, y-4, 4, 10); |
| | ctx.fillStyle = dk ? '#1a4a18' : '#3a8a30'; ctx.beginPath(); ctx.arc(tx, y-10, 8+Math.abs(i)*2, 0, 6.28); ctx.fill(); |
| | ctx.fillStyle = dk ? '#2a5a28' : '#55aa45'; ctx.beginPath(); ctx.arc(tx-2, y-12, 4, 0, 6.28); ctx.fill(); |
| | } |
| | |
| | ctx.font = 'bold 10px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; |
| | ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText('Willow Park', x+1, y+19); |
| | ctx.fillStyle = dk ? '#90a880' : '#fff'; ctx.fillText('Willow Park', x, y+18); |
| | } |
| | |
| | function drawTower(x, y, dk) { |
| | const w = 36, h = 56; |
| | const baseY = y + h/2; |
| | draw25DBox(x, baseY, w, h, '#6880a0', '#486078', '#7890b0', dk); |
| | |
| | ctx.fillStyle = '#888'; ctx.fillRect(x-1, baseY-h-10, 2, 10); |
| | ctx.fillStyle = '#e94560'; ctx.beginPath(); ctx.arc(x, baseY-h-10, 2, 0, 6.28); ctx.fill(); |
| | |
| | ctx.fillStyle = dk ? '#0a0e18' : '#384858'; |
| | ctx.fillRect(x-5, baseY-14, 10, 14); |
| | |
| | const wc = dk ? 'rgba(255,210,100,0.55)' : 'rgba(180,220,255,0.5)'; |
| | ctx.fillStyle = wc; |
| | for (let r = 0; r < 5; r++) |
| | for (let c = 0; c < 4; c++) |
| | ctx.fillRect(x-w/2+3+c*8.5, baseY-h+5+r*10, 6, 6); |
| | |
| | const swc = dk ? 'rgba(255,200,80,0.35)' : 'rgba(160,200,240,0.3)'; |
| | ctx.fillStyle = swc; |
| | for (let r = 0; r < 5; r++) { |
| | const wy = baseY-h+6+r*10; |
| | ctx.beginPath(); |
| | ctx.moveTo(x+w/2+1, wy); |
| | ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY); |
| | ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY+5); |
| | ctx.lineTo(x+w/2+1, wy+5); |
| | ctx.closePath(); ctx.fill(); |
| | } |
| | } |
| | |
| | function drawFactory(x, y, dk) { |
| | const w = 56, h = 34; |
| | const baseY = y + h/2; |
| | draw25DBox(x, baseY, w, h, '#8a7a6a', '#6a5a4a', '#9a8a78', dk); |
| | |
| | ctx.fillStyle = dk ? '#1a1815' : '#6a5a4a'; |
| | for (let i = 0; i < 3; i++) { |
| | const rx = x - w/2 + i*(w/3); |
| | ctx.beginPath(); |
| | ctx.moveTo(rx, baseY-h); ctx.lineTo(rx+w/6, baseY-h-10); ctx.lineTo(rx+w/3, baseY-h); |
| | ctx.closePath(); ctx.fill(); |
| | } |
| | |
| | ctx.fillStyle = dk ? '#3a3020' : '#706050'; |
| | ctx.fillRect(x+w/2-10, baseY-h-16, 8, 14); |
| | |
| | ctx.fillStyle = dk ? '#2a2018' : '#605040'; |
| | ctx.beginPath(); |
| | ctx.moveTo(x+w/2-2, baseY-h-16); ctx.lineTo(x+w/2-2+ISO_DX, baseY-h-16-ISO_DY); |
| | ctx.lineTo(x+w/2-2+ISO_DX, baseY-h-2-ISO_DY); ctx.lineTo(x+w/2-2, baseY-h-2); |
| | ctx.closePath(); ctx.fill(); |
| | |
| | ctx.fillStyle = `rgba(180,180,180,${dk?0.15:0.25})`; |
| | for (let i = 0; i < 3; i++) { |
| | const sy = baseY-h-20-i*8+Math.sin(animFrame*0.03+i)*3; |
| | ctx.beginPath(); ctx.arc(x+w/2-6+i*3, sy, 4+i*2, 0, 6.28); ctx.fill(); |
| | } |
| | |
| | ctx.fillStyle = dk ? '#1a1815' : '#5a4a3a'; |
| | ctx.fillRect(x-8, baseY-16, 16, 16); |
| | |
| | const wc = dk ? 'rgba(255,180,80,0.5)' : 'rgba(200,220,240,0.4)'; |
| | ctx.fillStyle = wc; |
| | for (let c = 0; c < 4; c++) ctx.fillRect(x-w/2+4+c*14, baseY-h+6, 10, 8); |
| | } |
| | |
| | function drawSchool(x, y, dk) { |
| | const w = 52, h = 36; |
| | const baseY = y + h/2; |
| | draw25DBox(x, baseY, w, h, '#c4a088', '#a48068', '#d4b098', dk); |
| | |
| | ctx.fillStyle = '#888'; ctx.fillRect(x+w/2-6, baseY-h-18, 2, 18); |
| | ctx.fillStyle = '#e94560'; |
| | ctx.beginPath(); |
| | ctx.moveTo(x+w/2-4, baseY-h-18); ctx.lineTo(x+w/2+8, baseY-h-14); ctx.lineTo(x+w/2-4, baseY-h-10); |
| | ctx.closePath(); ctx.fill(); |
| | |
| | ctx.fillStyle = dk ? '#1a1218' : '#6a4a3a'; |
| | ctx.fillRect(x-5, baseY-14, 10, 14); |
| | |
| | const wc = dk ? 'rgba(255,210,100,0.6)' : 'rgba(200,230,255,0.5)'; |
| | ctx.fillStyle = wc; |
| | for (let r = 0; r < 2; r++) |
| | for (let c = 0; c < 4; c++) |
| | ctx.fillRect(x-w/2+4+c*12, baseY-h+5+r*12, 8, 8); |
| | } |
| | |
| | function drawHospital(x, y, dk) { |
| | const w = 50, h = 40; |
| | const baseY = y + h/2; |
| | draw25DBox(x, baseY, w, h, '#c8ccd0', '#a0a4a8', '#d8dce0', dk); |
| | |
| | ctx.fillStyle = '#e94560'; |
| | ctx.fillRect(x-3, baseY-h+4, 6, 14); |
| | ctx.fillRect(x-7, baseY-h+8, 14, 6); |
| | |
| | ctx.fillStyle = dk ? '#0a1018' : '#4a5a6a'; |
| | ctx.fillRect(x-5, baseY-12, 10, 12); |
| | |
| | const wc = dk ? 'rgba(200,240,255,0.55)' : 'rgba(200,230,255,0.5)'; |
| | ctx.fillStyle = wc; |
| | for (let r = 0; r < 2; r++) |
| | for (let c = 0; c < 4; c++) |
| | ctx.fillRect(x-w/2+4+c*12, baseY-h+20+r*8, 8, 5); |
| | } |
| | |
| | function drawChurch(x, y, dk) { |
| | const w = 40, h = 36; |
| | const baseY = y + h/2; |
| | draw25DBox(x, baseY, w, h, '#c0b8a8', '#a09888', '#d0c8b8', dk); |
| | |
| | const stW = 12, stH = 18; |
| | draw25DBox(x, baseY-h, stW, stH, '#908880', '#706860', '#a09890', dk); |
| | |
| | ctx.fillStyle = dk ? '#1a1818' : '#908880'; |
| | ctx.beginPath(); |
| | ctx.moveTo(x-8, baseY-h-stH); ctx.lineTo(x, baseY-h-stH-12); ctx.lineTo(x+8, baseY-h-stH); |
| | ctx.closePath(); ctx.fill(); |
| | |
| | ctx.fillStyle = dk ? '#888' : '#d4c8a0'; |
| | ctx.fillRect(x-1.5, baseY-h-stH-20, 3, 10); |
| | ctx.fillRect(x-4, baseY-h-stH-18, 8, 3); |
| | |
| | ctx.fillStyle = dk ? 'rgba(100,150,255,0.5)' : 'rgba(80,120,200,0.4)'; |
| | ctx.beginPath(); ctx.arc(x, baseY-h-8, 4, 0, 6.28); ctx.fill(); |
| | |
| | ctx.fillStyle = dk ? '#1a1518' : '#5a4a3a'; |
| | ctx.fillRect(x-5, baseY-14, 10, 14); |
| | ctx.beginPath(); ctx.arc(x, baseY-14, 5, Math.PI, 0); ctx.fill(); |
| | |
| | const wc = dk ? 'rgba(255,200,100,0.5)' : 'rgba(200,180,120,0.45)'; |
| | ctx.fillStyle = wc; |
| | ctx.fillRect(x-w/2+4, baseY-h+6, 6, 10); |
| | ctx.fillRect(x+w/2-10, baseY-h+6, 6, 10); |
| | |
| | ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; |
| | ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText("St. Mary's", x+1, baseY+3); |
| | ctx.fillStyle = dk ? '#a0a8c0' : '#fff'; ctx.fillText("St. Mary's", x, baseY+2); |
| | } |
| | |
| | function drawCinema(x, y, dk) { |
| | const w = 48, h = 34; |
| | const baseY = y + h/2; |
| | draw25DBox(x, baseY, w, h, '#5a3060', '#3a1840', '#6a4070', dk); |
| | |
| | ctx.fillStyle = dk ? '#4a2040' : '#8a4080'; |
| | ctx.fillRect(x-w/2+4, baseY-h-8, w-8, 10); |
| | |
| | ctx.fillStyle = dk ? '#f0c040' : '#ffe880'; |
| | for (let i = 0; i < 8; i++) { |
| | const lx = x-w/2+8+i*5; |
| | const flicker = Math.sin(animFrame*0.1+i*0.8) > 0; |
| | if (flicker || !dk) { |
| | ctx.beginPath(); ctx.arc(lx, baseY-h-3, 1.5, 0, 6.28); ctx.fill(); |
| | } |
| | } |
| | ctx.fillStyle = dk ? '#f0c040' : '#fff'; |
| | ctx.font = 'bold 7px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; |
| | ctx.fillText('CINEMA', x, baseY-h-2); |
| | |
| | ctx.fillStyle = dk ? '#1a0818' : '#3a1830'; |
| | ctx.fillRect(x-5, baseY-12, 10, 12); |
| | |
| | const wc = dk ? 'rgba(255,200,100,0.4)' : 'rgba(200,180,240,0.5)'; |
| | ctx.fillStyle = wc; |
| | ctx.fillRect(x-w/2+4, baseY-h+8, 12, 16); |
| | ctx.fillRect(x+w/2-16, baseY-h+8, 12, 16); |
| | } |
| | |
| | function drawApartment(x, y, dk) { |
| | const w = 38, h = 48; |
| | const baseY = y + h/2; |
| | draw25DBox(x, baseY, w, h, '#a09890', '#807870', '#b0a8a0', dk); |
| | |
| | ctx.fillStyle = dk ? '#1a1520' : '#605850'; |
| | ctx.fillRect(x-4, baseY-10, 8, 10); |
| | |
| | const wc = dk ? 'rgba(255,210,100,0.55)' : 'rgba(180,220,255,0.45)'; |
| | for (let r = 0; r < 4; r++) |
| | for (let c = 0; c < 3; c++) { |
| | if (dk && ((r*3+c+animFrame) % 7 < 2)) continue; |
| | ctx.fillStyle = wc; |
| | ctx.fillRect(x-w/2+4+c*12, baseY-h+5+r*11, 8, 7); |
| | } |
| | |
| | const swc = dk ? 'rgba(255,200,80,0.35)' : 'rgba(160,200,240,0.3)'; |
| | for (let r = 0; r < 4; r++) { |
| | if (dk && ((r+animFrame) % 5 < 2)) continue; |
| | ctx.fillStyle = swc; |
| | const wy = baseY-h+6+r*11; |
| | ctx.beginPath(); |
| | ctx.moveTo(x+w/2+1, wy); ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY); |
| | ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY+6); ctx.lineTo(x+w/2+1, wy+6); |
| | ctx.closePath(); ctx.fill(); |
| | } |
| | } |
| | |
| | function drawSquare(x, y, dk) { |
| | |
| | ctx.fillStyle = dk ? '#2a2820' : '#b0a890'; |
| | ctx.beginPath(); ctx.ellipse(x, y, 50, 30, 0, 0, 6.28); ctx.fill(); |
| | |
| | ctx.fillStyle = dk ? '#1a1810' : '#908870'; |
| | ctx.beginPath(); ctx.ellipse(x, y+3, 50, 30, 0, 0, Math.PI); ctx.fill(); |
| | ctx.strokeStyle = dk ? '#3a3828' : '#c0b898'; |
| | ctx.lineWidth = 1.5; |
| | ctx.beginPath(); ctx.ellipse(x, y, 50, 30, 0, 0, 6.28); ctx.stroke(); |
| | |
| | ctx.strokeStyle = dk ? 'rgba(60,55,45,0.3)' : 'rgba(160,150,130,0.3)'; |
| | ctx.lineWidth = 0.5; |
| | for (let i = -3; i <= 3; i++) { |
| | ctx.beginPath(); ctx.moveTo(x+i*12, y-20); ctx.lineTo(x+i*12, y+20); ctx.stroke(); |
| | } |
| | |
| | ctx.fillStyle = dk ? '#4a5868' : '#788898'; |
| | ctx.beginPath(); ctx.ellipse(x, y+4, 14, 7, 0, 0, 6.28); ctx.fill(); |
| | ctx.fillStyle = dk ? '#1a2a3a' : '#708898'; |
| | ctx.beginPath(); ctx.ellipse(x, y+1, 12, 6, 0, 0, 6.28); ctx.fill(); |
| | |
| | ctx.fillStyle = dk ? '#3a4858' : '#8098a8'; |
| | ctx.fillRect(x-2, y-8, 4, 10); |
| | |
| | ctx.fillStyle = dk ? '#2a3a5a' : '#88b0d0'; |
| | ctx.beginPath(); ctx.ellipse(x, y-8, 7, 3, 0, 0, 6.28); ctx.fill(); |
| | |
| | if (!dk || animFrame % 3 < 2) { |
| | ctx.fillStyle = `rgba(100,180,220,${dk?0.3:0.5})`; |
| | const wy = y-14+Math.sin(animFrame*0.06)*2; |
| | ctx.beginPath(); ctx.arc(x, wy, 2, 0, 6.28); ctx.fill(); |
| | ctx.beginPath(); ctx.arc(x-3, wy+2, 1.5, 0, 6.28); ctx.fill(); |
| | ctx.beginPath(); ctx.arc(x+3, wy+2, 1.5, 0, 6.28); ctx.fill(); |
| | } |
| | |
| | ctx.fillStyle = dk ? '#3a2a15' : '#7a5a30'; |
| | ctx.fillRect(x-30, y+12, 12, 3); |
| | ctx.fillRect(x+18, y+12, 12, 3); |
| | |
| | ctx.font = 'bold 10px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; |
| | ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText('Town Square', x+1, y+23); |
| | ctx.fillStyle = dk ? '#a0a8b0' : '#fff'; ctx.fillText('Town Square', x, y+22); |
| | } |
| | |
| | function drawSportsField(x, y, dk) { |
| | |
| | ctx.fillStyle = dk ? '#1a3018' : '#4a9a38'; |
| | ctx.beginPath(); ctx.ellipse(x, y, 48, 24, 0, 0, 6.28); ctx.fill(); |
| | |
| | ctx.strokeStyle = dk ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.4)'; |
| | ctx.lineWidth = 1.5; |
| | ctx.beginPath(); ctx.ellipse(x, y, 44, 20, 0, 0, 6.28); ctx.stroke(); |
| | ctx.beginPath(); ctx.moveTo(x, y-20); ctx.lineTo(x, y+20); ctx.stroke(); |
| | ctx.beginPath(); ctx.arc(x, y, 8, 0, 6.28); ctx.stroke(); |
| | |
| | ctx.strokeStyle = dk ? '#555' : '#ddd'; ctx.lineWidth = 2; |
| | ctx.strokeRect(x-46, y-6, 4, 12); |
| | ctx.strokeRect(x+42, y-6, 4, 12); |
| | |
| | ctx.strokeStyle = dk ? '#3a2828' : '#c87850'; |
| | ctx.lineWidth = 3; |
| | ctx.beginPath(); ctx.ellipse(x, y, 52, 28, 0, 0, 6.28); ctx.stroke(); |
| | |
| | ctx.fillStyle = dk ? '#2a2828' : '#888'; |
| | ctx.fillRect(x-20, y+26, 40, 5); |
| | |
| | ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; |
| | ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText('Sports Field', x+1, y+33); |
| | ctx.fillStyle = dk ? '#90a880' : '#fff'; ctx.fillText('Sports Field', x, y+32); |
| | } |
| | |
| | function dim(hex, f) { |
| | if (hex.startsWith('rgb')) return hex; |
| | const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16); |
| | return `rgb(${~~(r*f)},${~~(g*f)},${~~(b*f)})`; |
| | } |
| | |
| | |
| | |
| | |
| | function drawCoupleLines(W, H) { |
| | const drawn = new Set(); |
| | for (const [id, a] of Object.entries(agents)) { |
| | if (!a.partner_id || drawn.has(id)) continue; |
| | const other = agents[a.partner_id]; |
| | if (!other) continue; |
| | drawn.add(id); drawn.add(a.partner_id); |
| | const p1 = agentPositions[id], p2 = agentPositions[a.partner_id]; |
| | if (!p1 || !p2) continue; |
| | ctx.strokeStyle = 'rgba(233, 30, 144, 0.4)'; |
| | ctx.lineWidth = 1.5; ctx.setLineDash([4,4]); |
| | ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); |
| | ctx.setLineDash([]); |
| | const mx = (p1.x+p2.x)/2, my = (p1.y+p2.y)/2 - 10; |
| | drawHeart(mx, my + Math.sin(animFrame*0.05)*3, 6, 'rgba(233,30,144,0.7)'); |
| | } |
| | } |
| | |
| | function drawHeart(x, y, s, color) { |
| | ctx.fillStyle = color; |
| | ctx.beginPath(); |
| | ctx.moveTo(x, y + s*0.4); |
| | ctx.bezierCurveTo(x, y - s*0.2, x - s, y - s*0.5, x - s, y + s*0.1); |
| | ctx.bezierCurveTo(x - s, y + s*0.6, x, y + s, x, y + s*1.2); |
| | ctx.bezierCurveTo(x, y + s, x + s, y + s*0.6, x + s, y + s*0.1); |
| | ctx.bezierCurveTo(x + s, y - s*0.5, x, y - s*0.2, x, y + s*0.4); |
| | ctx.fill(); |
| | } |
| | |
| | |
| | |
| | |
| | function drawConversationBubbles(W, H) { |
| | if (!conversationData.active) return; |
| | for (const conv of conversationData.active) { |
| | if (!conv.turns || conv.turns.length === 0) continue; |
| | const lastTurn = conv.turns[conv.turns.length - 1]; |
| | const speakerId = lastTurn.speaker_id; |
| | const pos = agentPositions[speakerId]; |
| | if (!pos) continue; |
| | |
| | const msg = (lastTurn.message || '').slice(0, 40) + (lastTurn.message.length > 40 ? '...' : ''); |
| | ctx.font = '9px Segoe UI'; |
| | const tw = ctx.measureText(msg).width; |
| | const bw = tw + 14, bh = 18; |
| | const bx = pos.x - bw/2, by = pos.y - 48; |
| | |
| | ctx.fillStyle = 'rgba(240,192,64,0.9)'; |
| | ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 4); ctx.fill(); |
| | ctx.beginPath(); |
| | ctx.moveTo(pos.x - 4, by + bh); ctx.lineTo(pos.x, by + bh + 6); ctx.lineTo(pos.x + 4, by + bh); |
| | ctx.fill(); |
| | ctx.fillStyle = '#1a1a2e'; |
| | ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; |
| | ctx.fillText(msg, pos.x, by + bh/2); |
| | } |
| | } |
| | |
| | |
| | |
| | |
| | function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) { |
| | const loc = agent.location || 'house_elena'; |
| | const allPos = getEffectivePositions(); |
| | const pos = allPos[loc]; |
| | if (!pos) return; |
| | |
| | const atLoc = byLoc[loc] || []; |
| | const localIdx = atLoc.findIndex(a => a.id === id); |
| | const count = atLoc.length; |
| | |
| | |
| | if (pos.type === 'street') { |
| | const row = Math.floor(localIdx / 10); |
| | const col = localIdx % 10; |
| | const spread = Math.min(count, 10); |
| | const step = 0.025; |
| | const startX = pos.x - (spread-1)*step/2; |
| | agentTargets[id] = { |
| | x: (startX + col * step) * W, |
| | y: pos.y * H + 16 + row * 18 + (col % 2) * 10 |
| | }; |
| | } else { |
| | |
| | locCrowdCount[loc] = count; |
| | |
| | let ox, oy; |
| | if (count <= 10) { |
| | |
| | const baseR = pos.type === 'house' ? 18 : (pos.type === 'park' || pos.type === 'square' || pos.type === 'sports' ? 40 : 28); |
| | const mpr = pos.type === 'house' ? 4 : 6; |
| | const iRow = Math.floor(localIdx / mpr); |
| | const iCol = localIdx % mpr; |
| | const rCnt = Math.min(count - iRow * mpr, mpr); |
| | const rad = baseR + iRow * 18; |
| | const stp = Math.PI / Math.max(rCnt + 1, 2); |
| | const ang = stp * (iCol + 1); |
| | ox = Math.cos(ang) * rad - rad / 3; |
| | oy = Math.sin(ang) * rad * 0.5 + (pos.type === 'house' ? 20 : 28) + iRow * 8; |
| | } else { |
| | |
| | const isWide = pos.type === 'park' || pos.type === 'square' || pos.type === 'sports'; |
| | const areaW = isWide ? 290 : 210; |
| | const areaH = isWide ? 210 : 160; |
| | const cols = Math.ceil(Math.sqrt(count * 1.3)); |
| | const rows = Math.ceil(count / cols); |
| | const cellW = areaW / cols; |
| | const cellH = areaH / rows; |
| | const col = localIdx % cols; |
| | const iRow = Math.floor(localIdx / cols); |
| | |
| | const jx = ((globalIdx * 7 + 3) % 11) - 5; |
| | const jy = ((globalIdx * 11 + 7) % 9) - 4; |
| | ox = -(cols - 1) * cellW / 2 + col * cellW + jx; |
| | oy = 26 + iRow * cellH + jy; |
| | } |
| | |
| | agentTargets[id] = { x: pos.x * W + ox, y: pos.y * H + oy }; |
| | } |
| | if (!agentPositions[id]) agentPositions[id] = {...agentTargets[id]}; |
| | |
| | |
| | |
| | |
| | const prevLoc = agentPrevLocations[id]; |
| | if (agent.state === 'moving' && prevLoc && prevLoc !== loc) { |
| | const prevPos = allPos[prevLoc]; |
| | if (prevPos && agentPositions[id]) { |
| | const srcX = prevPos.x, srcY = prevPos.y; |
| | const dstX = pos.x, dstY = pos.y; |
| | const hRoads = [0.22, 0.28, 0.43, 0.58, 0.72, 0.80]; |
| | const vRoads = [0.08, 0.15, 0.30, 0.50, 0.70, 0.85, 0.92]; |
| | const midY = (srcY + dstY) / 2, midX = (srcX + dstX) / 2; |
| | const bestH = hRoads.reduce((b, r) => Math.abs(midY - r) < Math.abs(midY - b) ? r : b, hRoads[0]); |
| | const bestV = vRoads.reduce((b, r) => Math.abs(midX - r) < Math.abs(midX - b) ? r : b, vRoads[0]); |
| | const lo = (localIdx % 3 - 1) * 5; |
| | if (Math.abs(dstX - srcX) >= Math.abs(dstY - srcY)) { |
| | |
| | agentWaypoints[id] = [ |
| | { x: srcX * W + lo, y: bestH * H }, |
| | { x: dstX * W + lo, y: bestH * H }, |
| | ]; |
| | } else { |
| | |
| | agentWaypoints[id] = [ |
| | { x: bestV * W, y: srcY * H + lo }, |
| | { x: bestV * W, y: dstY * H + lo }, |
| | ]; |
| | } |
| | } |
| | } |
| | agentPrevLocations[id] = loc; |
| | } |
| | |
| | |
| | |
| | |
| | function drawPerson(id, agent, globalIdx, W, H) { |
| | const pos = agentPositions[id]; |
| | if (!pos) return; |
| | const ax = pos.x, ay = pos.y; |
| | const color = AGENT_COLORS[globalIdx % AGENT_COLORS.length]; |
| | const isSel = id === selectedAgentId; |
| | const isHov = id === hoveredAgent; |
| | const gender = agent.gender || 'unknown'; |
| | |
| | const crowdN = locCrowdCount[agent.location] || 1; |
| | const crowdShrink = Math.max(0.48, Math.sqrt(12 / Math.max(12, crowdN))); |
| | const scale = isSel ? 1.15 : (isHov ? 1.0 : 0.82 * crowdShrink); |
| | const isMoving = agent.state === 'moving'; |
| | const isSleeping = agent.state === 'sleeping'; |
| | |
| | |
| | const tgt = agentTargets[id]; |
| | const movingVisually = tgt && Math.hypot(ax - tgt.x, ay - tgt.y) > 4; |
| | const walkAnim = isMoving || movingVisually; |
| | const walkPhase = animFrame * 0.28; |
| | const bounce = walkAnim ? Math.sin(walkPhase) * 2.5 : 0; |
| | const legSwing = walkAnim ? Math.sin(walkPhase) * 12 : 0; |
| | const armSwing = walkAnim ? Math.sin(walkPhase) * 10 : 0; |
| | const tY = -10 + bounce; |
| | |
| | |
| | const facingRight = agentFacingRight[id] !== false; |
| | const movingUp = agentMovingUp[id] === true; |
| | |
| | |
| | const skinTones = ['#f5dbb8','#d4a574','#c68642','#8d5524','#e8c4a0','#f0c090']; |
| | const skin = skinTones[(globalIdx * 7 + 3) % skinTones.length]; |
| | const hairColor = dim(color, 0.45); |
| | |
| | const pantsColor = dim(color, 0.55); |
| | const shoeColor = currentTimeOfDay === 'night' ? '#1a1a1a' : '#2a2010'; |
| | |
| | ctx.save(); |
| | ctx.translate(ax, ay); |
| | ctx.scale(scale, scale); |
| | if (isSel) { ctx.shadowColor = color; ctx.shadowBlur = 14; } |
| | |
| | |
| | if (agent.is_player) { |
| | ctx.beginPath(); |
| | ctx.arc(0, 0, 22, 0, Math.PI * 2); |
| | ctx.strokeStyle = '#f0c040'; |
| | ctx.lineWidth = 2.5; |
| | ctx.globalAlpha = 0.8; |
| | ctx.stroke(); |
| | ctx.globalAlpha = 1.0; |
| | } |
| | |
| | |
| | |
| | |
| | if (isSleeping) { |
| | const bw = 22, bh = 10; |
| | |
| | ctx.fillStyle = '#5c3d1a'; |
| | ctx.beginPath(); ctx.roundRect(-bw/2 - 1, -bh/2 - 1, bw + 2, bh + 2, 2); ctx.fill(); |
| | |
| | ctx.fillStyle = '#c9a97a'; |
| | ctx.beginPath(); ctx.roundRect(-bw/2, -bh/2, bw, bh, 1); ctx.fill(); |
| | |
| | ctx.fillStyle = '#f0e8e0'; |
| | ctx.beginPath(); ctx.roundRect(bw/2 - 9, -bh/2 + 1, 8, bh - 2, 2); ctx.fill(); |
| | |
| | ctx.fillStyle = color; |
| | ctx.beginPath(); ctx.roundRect(-bw/2 + 1, -bh/2 + 1.5, bw - 11, bh - 3, 1); ctx.fill(); |
| | |
| | ctx.fillStyle = dim(color, 1.25); |
| | ctx.beginPath(); ctx.roundRect(-bw/2 + 1, -bh/2 + 1.5, bw - 11, 2.5, 1); ctx.fill(); |
| | |
| | ctx.fillStyle = skin; |
| | ctx.beginPath(); ctx.arc(bw/2 - 5, 0, 3.5, 0, 6.28); ctx.fill(); |
| | |
| | ctx.fillStyle = hairColor; |
| | ctx.beginPath(); ctx.arc(bw/2 - 5, -0.5, 4.2, Math.PI * 0.85, Math.PI * 2.15); ctx.fill(); |
| | |
| | ctx.strokeStyle = dim(skin, 0.45); ctx.lineWidth = 0.7; ctx.lineCap = 'round'; |
| | ctx.beginPath(); ctx.arc(bw/2 - 3.5, -0.3, 1.0, Math.PI * 1.1, Math.PI * 0.0); ctx.stroke(); |
| | ctx.beginPath(); ctx.arc(bw/2 - 6.5, -0.3, 1.0, Math.PI * 1.1, Math.PI * 0.0); ctx.stroke(); |
| | |
| | const zt = animFrame * 0.04; |
| | ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'left'; |
| | for (let i = 0; i < 3; i++) { |
| | ctx.globalAlpha = 0.3 + i * 0.25; ctx.fillStyle = '#8ab4f8'; |
| | ctx.fillText('z', bw/2 - 2 + i * 5, -bh/2 - 4 - i * 5 + Math.sin(zt + i) * 2); |
| | } |
| | ctx.globalAlpha = 1; |
| | if (agent.partner_id) drawHeart(bw/2 - 5, -bh/2 - 14 + Math.sin(animFrame * 0.04) * 2, 3.5, 'rgba(233,30,144,0.8)'); |
| | if (isSel) { |
| | ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([3, 3]); |
| | ctx.beginPath(); ctx.roundRect(-bw/2 - 4, -bh/2 - 4, bw + 8, bh + 8, 4); ctx.stroke(); |
| | ctx.setLineDash([]); |
| | } |
| | ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; |
| | ctx.restore(); |
| | const fnS = (agent.name || id).split(' ')[0]; |
| | ctx.font = `${isSel ? 'bold ' : ''}8px Segoe UI`; |
| | ctx.textAlign = 'center'; ctx.textBaseline = 'top'; |
| | ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(fnS, ax + 1, ay + 8 * scale + 1); |
| | ctx.fillStyle = isSel ? '#fff' : '#c8c8d8'; ctx.fillText(fnS, ax, ay + 8 * scale); |
| | return; |
| | } |
| | |
| | if (movingUp) { |
| | |
| | |
| | |
| | |
| | const twB = gender === 'female' ? 7 : 8; |
| | const bwB = gender === 'female' ? 4.5 : 6; |
| | const hxB = 1, hyB = tY - 10, hrB = 6.5; |
| | |
| | |
| | ctx.fillStyle = 'rgba(0,0,0,0.22)'; |
| | ctx.beginPath(); ctx.ellipse(3, 13, 8, 3, 0.2, 0, 6.28); ctx.fill(); |
| | |
| | |
| | const lFBX = -2.5 - legSwing * 0.35, lFBY = 12 + bounce + legSwing; |
| | const rFBX = 2.5 + legSwing * 0.35, rFBY = 12 + bounce - legSwing; |
| | ctx.fillStyle = shoeColor; |
| | ctx.beginPath(); ctx.roundRect(lFBX - 3, lFBY - 1, 5, 2.5, 1); ctx.fill(); |
| | ctx.beginPath(); ctx.roundRect(rFBX - 1.5, rFBY - 1, 5, 2.5, 1); ctx.fill(); |
| | |
| | |
| | ctx.lineWidth = 2.8; ctx.lineCap = 'round'; |
| | ctx.strokeStyle = pantsColor; |
| | ctx.beginPath(); ctx.moveTo(-1.5, 4 + bounce); |
| | ctx.lineTo(-1.5 - legSwing * 0.18, 8 + bounce + legSwing * 0.5); |
| | ctx.lineTo(lFBX, lFBY); ctx.stroke(); |
| | ctx.beginPath(); ctx.moveTo(1.5, 4 + bounce); |
| | ctx.lineTo(1.5 + legSwing * 0.18, 8 + bounce - legSwing * 0.5); |
| | ctx.lineTo(rFBX, rFBY); ctx.stroke(); |
| | |
| | |
| | const tGradB = ctx.createLinearGradient(-twB - 1, tY, twB + 1, tY + 13); |
| | tGradB.addColorStop(0.00, dim(color, 0.62)); |
| | tGradB.addColorStop(0.50, dim(color, 0.78)); |
| | tGradB.addColorStop(1.00, dim(color, 0.50)); |
| | ctx.fillStyle = tGradB; |
| | ctx.beginPath(); |
| | ctx.moveTo(-bwB, tY + 13); ctx.lineTo(-twB, tY + 2); |
| | ctx.quadraticCurveTo(-twB, tY - 1, -twB + 3, tY - 1); |
| | ctx.lineTo(twB - 3, tY - 1); |
| | ctx.quadraticCurveTo(twB, tY - 1, twB, tY + 2); |
| | ctx.lineTo(bwB, tY + 13); ctx.closePath(); ctx.fill(); |
| | ctx.strokeStyle = dim(color, 0.38); ctx.lineWidth = 0.8; |
| | ctx.beginPath(); ctx.moveTo(0, tY + 2); ctx.lineTo(0, tY + 12); ctx.stroke(); |
| | if (gender === 'female') { |
| | const sGB = ctx.createLinearGradient(-bwB - 2, tY + 13, bwB + 2, tY + 17); |
| | sGB.addColorStop(0, dim(color, 0.65)); sGB.addColorStop(1, dim(color, 0.50)); |
| | ctx.fillStyle = sGB; |
| | ctx.beginPath(); |
| | ctx.moveTo(-bwB, tY + 13); ctx.lineTo(-bwB - 2.5, tY + 17 + bounce); |
| | ctx.lineTo(bwB + 2.5, tY + 17 + bounce); ctx.lineTo(bwB, tY + 13); |
| | ctx.closePath(); ctx.fill(); |
| | } |
| | |
| | |
| | const sBy = tY + 2; |
| | ctx.strokeStyle = dim(skin, 0.78); ctx.lineWidth = 1.8; ctx.lineCap = 'round'; |
| | ctx.beginPath(); ctx.moveTo(-twB + 1, sBy); |
| | ctx.lineTo(-twB - 3 + armSwing * 0.3, sBy + 5 + armSwing * 0.4); |
| | ctx.lineTo(-twB - 4.5 + armSwing * 0.7, sBy + 9 + armSwing * 0.85); ctx.stroke(); |
| | ctx.beginPath(); ctx.moveTo(twB - 1, sBy); |
| | ctx.lineTo(twB + 3 - armSwing * 0.3, sBy + 5 - armSwing * 0.4); |
| | ctx.lineTo(twB + 4.5 - armSwing * 0.7, sBy + 9 - armSwing * 0.85); ctx.stroke(); |
| | ctx.fillStyle = dim(skin, 0.78); |
| | ctx.beginPath(); ctx.arc(-twB - 4.5 + armSwing * 0.7, sBy + 9 + armSwing * 0.85, 1.1, 0, 6.28); ctx.fill(); |
| | ctx.beginPath(); ctx.arc(twB + 4.5 - armSwing * 0.7, sBy + 9 - armSwing * 0.85, 1.1, 0, 6.28); ctx.fill(); |
| | |
| | |
| | ctx.fillStyle = dim(skin, 0.72); ctx.fillRect(-1.5, tY - 3.5, 3, 4); |
| | |
| | |
| | ctx.fillStyle = dim(skin, 0.78); |
| | ctx.beginPath(); ctx.arc(hxB, hyB, hrB, 0, 6.28); ctx.fill(); |
| | |
| | |
| | ctx.fillStyle = hairColor; |
| | if (gender === 'female') { |
| | ctx.beginPath(); ctx.arc(hxB, hyB - 0.5, hrB + 1.0, Math.PI * 1.0, Math.PI * 2.0); ctx.fill(); |
| | ctx.beginPath(); ctx.ellipse(hxB - hrB + 0.5, hyB + 4, 2.5, hrB * 0.95, -0.1, 0, 6.28); ctx.fill(); |
| | ctx.beginPath(); ctx.ellipse(hxB + hrB - 0.5, hyB + 4, 2.2, hrB * 0.90, 0.1, 0, 6.28); ctx.fill(); |
| | } else if (gender === 'male') { |
| | ctx.beginPath(); ctx.arc(hxB, hyB - 0.5, hrB + 0.5, Math.PI * 1.0, Math.PI * 2.0); ctx.fill(); |
| | ctx.fillRect(hxB - hrB - 0.5, hyB - 0.5, 3, hrB * 0.6); |
| | } else { |
| | ctx.beginPath(); ctx.arc(hxB, hyB - 0.5, hrB + 0.8, Math.PI * 1.0, Math.PI * 2.0); ctx.fill(); |
| | ctx.beginPath(); ctx.ellipse(hxB - hrB + 0.5, hyB + 3, 2.2, hrB * 0.75, -0.1, 0, 6.28); ctx.fill(); |
| | } |
| | |
| | if (agent.partner_id) drawHeart(hxB + 1, hyB - hrB - 10 + Math.sin(animFrame * 0.04) * 2, 3.5, 'rgba(233,30,144,0.8)'); |
| | if (isSel) { |
| | ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([3, 3]); |
| | ctx.beginPath(); ctx.arc(hxB, hyB, hrB + 3, 0, 6.28); ctx.stroke(); ctx.setLineDash([]); |
| | } |
| | ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; |
| | ctx.restore(); |
| | const fnB = (agent.name || id).split(' ')[0]; |
| | ctx.font = `${isSel ? 'bold ' : ''}8px Segoe UI`; |
| | ctx.textAlign = 'center'; ctx.textBaseline = 'top'; |
| | ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(fnB, ax + 1, ay + 14 * scale + 1); |
| | ctx.fillStyle = isSel ? '#fff' : '#c8c8d8'; ctx.fillText(fnB, ax, ay + 14 * scale); |
| | const mVB = agent.mood || 0, mwBL = 16, mxbB = ax - 8, mybB = ay + 14 * scale + 11; |
| | ctx.fillStyle = 'rgba(15,52,96,0.45)'; ctx.fillRect(mxbB, mybB, mwBL, 2.5); |
| | const mfB = (mVB + 1) / 2; |
| | ctx.fillStyle = mfB > 0.6 ? '#4ecca3' : (mfB > 0.3 ? '#f0c040' : '#e94560'); |
| | ctx.fillRect(mxbB, mybB, mwBL * mfB, 2.5); |
| | return; |
| | } |
| | |
| | if (walkAnim) { |
| | |
| | |
| | |
| | |
| | |
| | if (!facingRight) ctx.scale(-1, 1); |
| | const hr = 6.5; |
| | const hx = 2, hy = tY - 11; |
| | const pStride = Math.sin(walkPhase) * 12; |
| | |
| | |
| | ctx.fillStyle = 'rgba(0,0,0,0.20)'; |
| | ctx.beginPath(); ctx.ellipse(2, 13, 10, 2.5, 0, 0, 6.28); ctx.fill(); |
| | |
| | |
| | const bKX = -pStride * 0.42, bKY = 8 + bounce; |
| | const bFX = -pStride * 0.75, bFY = 14 + bounce; |
| | ctx.strokeStyle = dim(pantsColor, 0.62); ctx.lineWidth = 2.5; ctx.lineCap = 'round'; |
| | ctx.beginPath(); ctx.moveTo(1.5, 4 + bounce); ctx.lineTo(bKX, bKY); ctx.lineTo(bFX, bFY); ctx.stroke(); |
| | ctx.fillStyle = dim(shoeColor, 0.62); |
| | ctx.beginPath(); ctx.roundRect(bFX - 2, bFY - 1, 6.5, 2.5, 1); ctx.fill(); |
| | |
| | |
| | const bAS = -Math.sin(walkPhase) * 8; |
| | const bEX = 1.5 + bAS * 0.42, bEY = tY + 7; |
| | const bHX = bEX + bAS * 0.5, bHY = bEY + 4; |
| | ctx.strokeStyle = dim(skin, 0.65); ctx.lineWidth = 1.7; |
| | ctx.beginPath(); ctx.moveTo(2, tY + 2); ctx.lineTo(bEX, bEY); ctx.lineTo(bHX, bHY); ctx.stroke(); |
| | ctx.fillStyle = dim(skin, 0.65); |
| | ctx.beginPath(); ctx.arc(bHX, bHY, 1.0, 0, 6.28); ctx.fill(); |
| | |
| | |
| | const pGrad = ctx.createLinearGradient(-0.5, tY, 5.5, tY); |
| | pGrad.addColorStop(0.00, dim(color, 1.22)); |
| | pGrad.addColorStop(0.45, color); |
| | pGrad.addColorStop(1.00, dim(color, 0.50)); |
| | ctx.fillStyle = pGrad; |
| | ctx.beginPath(); |
| | ctx.moveTo(-0.5, tY + 13); ctx.lineTo(-0.5, tY + 2); |
| | ctx.quadraticCurveTo(-0.5, tY - 1, 1, tY - 1); |
| | ctx.lineTo(3.5, tY - 1); ctx.quadraticCurveTo(5.5, tY - 1, 5.5, tY + 2); |
| | ctx.lineTo(5.5, tY + 13); ctx.closePath(); ctx.fill(); |
| | ctx.fillStyle = 'rgba(255,255,255,0.11)'; |
| | ctx.beginPath(); ctx.ellipse(0.5, tY + 4, 1.3, 4, 0, 0, 6.28); ctx.fill(); |
| | ctx.strokeStyle = dim(color, 0.42); ctx.lineWidth = 0.8; |
| | ctx.beginPath(); ctx.moveTo(-0.5, tY + 13); ctx.lineTo(5.5, tY + 13); ctx.stroke(); |
| | if (gender === 'female') { |
| | const sG = ctx.createLinearGradient(0, tY + 13, 0, tY + 19); |
| | sG.addColorStop(0, color); sG.addColorStop(1, dim(color, 0.68)); |
| | ctx.fillStyle = sG; |
| | ctx.beginPath(); |
| | ctx.moveTo(-0.5, tY + 13); ctx.lineTo(-2.5, tY + 19 + bounce); |
| | ctx.lineTo(7.5, tY + 19 + bounce); ctx.lineTo(5.5, tY + 13); |
| | ctx.closePath(); ctx.fill(); |
| | } |
| | |
| | |
| | const fKX = pStride * 0.42 + 1.5, fKY = 8 + bounce; |
| | const fFX = pStride * 0.75 + 1.5, fFY = 14 + bounce; |
| | ctx.strokeStyle = pantsColor; ctx.lineWidth = 2.9; |
| | ctx.beginPath(); ctx.moveTo(2, 4 + bounce); ctx.lineTo(fKX, fKY); ctx.lineTo(fFX, fFY); ctx.stroke(); |
| | ctx.fillStyle = shoeColor; |
| | ctx.beginPath(); ctx.roundRect(fFX - 1.5, fFY - 1, 8, 2.5, 1); ctx.fill(); |
| | |
| | |
| | const fAS = Math.sin(walkPhase) * 8; |
| | const fEX = 2.5 + fAS * 0.42, fEY = tY + 7; |
| | const fHX = fEX + fAS * 0.5, fHY = fEY + 4; |
| | ctx.strokeStyle = skin; ctx.lineWidth = 1.8; |
| | ctx.beginPath(); ctx.moveTo(2.5, tY + 2); ctx.lineTo(fEX, fEY); ctx.lineTo(fHX, fHY); ctx.stroke(); |
| | ctx.fillStyle = skin; |
| | ctx.beginPath(); ctx.arc(fHX, fHY, 1.1, 0, 6.28); ctx.fill(); |
| | |
| | |
| | ctx.fillStyle = skin; ctx.fillRect(0.5, tY - 4, 2.5, 4); |
| | |
| | |
| | |
| | ctx.fillStyle = dim(skin, 0.80); |
| | ctx.beginPath(); ctx.arc(hx, hy, hr, 0, 6.28); ctx.fill(); |
| | |
| | ctx.fillStyle = skin; |
| | ctx.beginPath(); ctx.arc(hx, hy, hr, -Math.PI * 0.55, Math.PI * 0.55); |
| | ctx.lineTo(hx, hy); ctx.closePath(); ctx.fill(); |
| | |
| | ctx.fillStyle = 'rgba(255,255,255,0.10)'; |
| | ctx.beginPath(); ctx.arc(hx + 2.5, hy - 2, hr * 0.48, 0, 6.28); ctx.fill(); |
| | |
| | ctx.fillStyle = dim(skin, 0.86); |
| | ctx.beginPath(); ctx.ellipse(hx - hr + 1.5, hy + 0.5, 1.8, 2.5, 0.1, 0, 6.28); ctx.fill(); |
| | |
| | |
| | ctx.fillStyle = hairColor; |
| | ctx.beginPath(); ctx.arc(hx, hy - 0.5, hr + 0.8, Math.PI * 1.08, Math.PI * 0.1); ctx.fill(); |
| | if (gender === 'female') { |
| | ctx.beginPath(); ctx.ellipse(hx - hr + 0.5, hy + 3, 2.5, hr * 0.82, 0.15, 0, 6.28); ctx.fill(); |
| | } else { |
| | ctx.fillRect(hx - hr - 0.5, hy - 1.5, 3, hr * 0.50); |
| | } |
| | |
| | |
| | const moodVP = agent.mood || 0; |
| | |
| | const pBrY = hy - 3.5; |
| | const pSadT = moodVP < 0 ? Math.min(1, -moodVP) * 1.5 : 0; |
| | const pHapR = moodVP > 0 ? moodVP * 0.7 : 0; |
| | ctx.strokeStyle = hairColor; ctx.lineWidth = 1.0; ctx.lineCap = 'round'; |
| | ctx.beginPath(); ctx.moveTo(hx + 1.5, pBrY - pHapR); ctx.lineTo(hx + 5.0, pBrY - pHapR + pSadT); ctx.stroke(); |
| | |
| | ctx.fillStyle = '#1a1010'; |
| | ctx.beginPath(); ctx.ellipse(hx + 3.5, hy - 0.8, 0.85, 1.1, 0.12, 0, 6.28); ctx.fill(); |
| | ctx.fillStyle = 'rgba(255,255,255,0.55)'; |
| | ctx.beginPath(); ctx.arc(hx + 3.8, hy - 1.1, 0.35, 0, 6.28); ctx.fill(); |
| | |
| | ctx.fillStyle = dim(skin, 0.73); |
| | ctx.beginPath(); ctx.moveTo(hx + hr - 3, hy - 2); |
| | ctx.quadraticCurveTo(hx + hr + 1.8, hy - 0.5, hx + hr - 1.5, hy + 2); |
| | ctx.closePath(); ctx.fill(); |
| | |
| | const pMC = Math.max(-2.5, Math.min(2.5, moodVP * 6)); |
| | const pMX0 = hx + hr - 4.2, pMX1 = hx + hr - 1.3, pMY = hy + 3.6; |
| | ctx.strokeStyle = moodVP > 0 ? 'rgba(210,90,90,0.85)' : 'rgba(140,70,80,0.85)'; |
| | ctx.lineWidth = 0.9; |
| | ctx.beginPath(); ctx.moveTo(pMX0, pMY); |
| | ctx.quadraticCurveTo((pMX0 + pMX1) / 2, pMY - pMC, pMX1, pMY); ctx.stroke(); |
| | |
| | |
| | if (agent.state === 'in_conversation') { |
| | ctx.fillStyle = 'rgba(240,192,64,0.9)'; |
| | ctx.beginPath(); ctx.roundRect(hr + 3, hy - hr - 6, 16, 11, 3); ctx.fill(); |
| | ctx.beginPath(); ctx.moveTo(hr + 3, hy - hr - 2); ctx.lineTo(hr - 1, hy); ctx.lineTo(hr + 7, hy - hr - 2); ctx.fill(); |
| | ctx.fillStyle = '#1a1a2e'; ctx.font = 'bold 6px Segoe UI'; |
| | ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; |
| | ctx.fillText('...', hr + 11, hy - hr); |
| | } |
| | if (isSleeping) { |
| | const t = animFrame * 0.04; ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'left'; |
| | for (let i = 0; i < 3; i++) { |
| | ctx.globalAlpha = 0.3 + i * 0.25; ctx.fillStyle = '#8ab4f8'; |
| | ctx.fillText('z', hr + 2 + i * 4, hy - hr - i * 6 + Math.sin(t + i) * 2); |
| | } |
| | ctx.globalAlpha = 1; |
| | } |
| | if (agent.partner_id) drawHeart(hx + 1, hy - hr - 10 + Math.sin(animFrame * 0.04) * 2, 3.5, 'rgba(233,30,144,0.8)'); |
| | if (isSel) { |
| | ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([3, 3]); |
| | ctx.beginPath(); ctx.arc(hx, hy, hr + 3, 0, 6.28); ctx.stroke(); ctx.setLineDash([]); |
| | } |
| | ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; |
| | ctx.restore(); |
| | |
| | const fnP = (agent.name || id).split(' ')[0]; |
| | ctx.font = `${isSel ? 'bold ' : ''}8px Segoe UI`; |
| | ctx.textAlign = 'center'; ctx.textBaseline = 'top'; |
| | ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(fnP, ax + 1, ay + 14 * scale + 1); |
| | ctx.fillStyle = isSel ? '#fff' : '#c8c8d8'; ctx.fillText(fnP, ax, ay + 14 * scale); |
| | const mVP = agent.mood || 0, mwP = 16, mxbP = ax - 8, mybP = ay + 14 * scale + 11; |
| | ctx.fillStyle = 'rgba(15,52,96,0.45)'; ctx.fillRect(mxbP, mybP, mwP, 2.5); |
| | const mfP = (mVP + 1) / 2; |
| | ctx.fillStyle = mfP > 0.6 ? '#4ecca3' : (mfP > 0.3 ? '#f0c040' : '#e94560'); |
| | ctx.fillRect(mxbP, mybP, mwP * mfP, 2.5); |
| | return; |
| | } |
| | |
| | |
| | ctx.fillStyle = 'rgba(0,0,0,0.22)'; |
| | ctx.beginPath(); ctx.ellipse(3, 13, 8, 3, 0.2, 0, 6.28); ctx.fill(); |
| | |
| | |
| | const lKneeX = walkAnim ? -1.5 - legSwing * 0.18 : -1.5; |
| | const rKneeX = walkAnim ? 1.5 + legSwing * 0.18 : 1.5; |
| | const lFootX = walkAnim ? -2.5 - legSwing * 0.35 : -2.5; |
| | const rFootX = walkAnim ? 2.5 + legSwing * 0.35 : 2.5; |
| | const lFootY = 12 + bounce + legSwing; |
| | const rFootY = 12 + bounce - legSwing; |
| | |
| | ctx.fillStyle = shoeColor; |
| | ctx.beginPath(); ctx.roundRect(lFootX - 3, lFootY - 1, 5, 2.5, 1); ctx.fill(); |
| | ctx.beginPath(); ctx.roundRect(rFootX - 1.5, rFootY - 1, 5, 2.5, 1); ctx.fill(); |
| | |
| | |
| | ctx.lineWidth = walkAnim ? 2.8 : 2.2; |
| | ctx.lineCap = 'round'; |
| | |
| | ctx.strokeStyle = gender === 'female' ? dim(pantsColor, 0.85) : pantsColor; |
| | ctx.beginPath(); ctx.moveTo(-1.5, 4 + bounce); |
| | ctx.lineTo(lKneeX, 8 + bounce + legSwing * 0.5); |
| | ctx.lineTo(lFootX, lFootY); ctx.stroke(); |
| | |
| | ctx.beginPath(); ctx.moveTo(1.5, 4 + bounce); |
| | ctx.lineTo(rKneeX, 8 + bounce - legSwing * 0.5); |
| | ctx.lineTo(rFootX, rFootY); ctx.stroke(); |
| | |
| | ctx.strokeStyle = dim(gender === 'female' ? dim(pantsColor, 0.85) : pantsColor, 1.2); |
| | ctx.lineWidth = 1.2; |
| | ctx.beginPath(); ctx.moveTo(1.5 + 1.5, 4 + bounce); |
| | ctx.lineTo(rKneeX + 1.5, 8 + bounce - legSwing * 0.5); |
| | ctx.lineTo(rFootX + 1.5, rFootY); ctx.stroke(); |
| | |
| | |
| | const tw = gender === 'female' ? 7 : 8; |
| | const bw = gender === 'female' ? 4.5 : 6; |
| | |
| | |
| | function torsoPath() { |
| | ctx.beginPath(); |
| | ctx.moveTo(-bw, tY + 13); |
| | ctx.lineTo(-tw, tY + 2); |
| | ctx.quadraticCurveTo(-tw, tY - 1, -tw + 3, tY - 1); |
| | ctx.lineTo(tw - 3, tY - 1); |
| | ctx.quadraticCurveTo(tw, tY - 1, tw, tY + 2); |
| | ctx.lineTo(bw, tY + 13); |
| | ctx.closePath(); |
| | } |
| | |
| | |
| | const tGrad = ctx.createLinearGradient(-tw - 1, tY, tw + 1, tY + 13); |
| | tGrad.addColorStop(0.00, dim(color, 1.30)); |
| | tGrad.addColorStop(0.22, dim(color, 1.08)); |
| | tGrad.addColorStop(0.52, color); |
| | tGrad.addColorStop(0.78, dim(color, 0.72)); |
| | tGrad.addColorStop(1.00, dim(color, 0.45)); |
| | ctx.fillStyle = tGrad; |
| | torsoPath(); ctx.fill(); |
| | |
| | |
| | const chGrad = ctx.createRadialGradient(-2.5, tY + 3.5, 0, -2.5, tY + 3.5, 6.5); |
| | chGrad.addColorStop(0, 'rgba(255,255,255,0.20)'); |
| | chGrad.addColorStop(1, 'rgba(255,255,255,0)'); |
| | ctx.fillStyle = chGrad; |
| | torsoPath(); ctx.fill(); |
| | |
| | |
| | ctx.strokeStyle = dim(color, 0.35); ctx.lineWidth = 1; ctx.lineCap = 'round'; |
| | ctx.beginPath(); ctx.moveTo(tw, tY + 2); ctx.lineTo(bw, tY + 13); ctx.stroke(); |
| | |
| | |
| | ctx.strokeStyle = dim(color, 0.45); ctx.lineWidth = 0.9; |
| | ctx.beginPath(); ctx.moveTo(-bw + 0.5, tY + 13); ctx.lineTo(bw - 0.5, tY + 13); ctx.stroke(); |
| | |
| | |
| | if (gender === 'female') { |
| | const skirtGrad = ctx.createLinearGradient(-bw - 2, tY + 13, bw + 2, tY + 17); |
| | skirtGrad.addColorStop(0, color); |
| | skirtGrad.addColorStop(1, dim(color, 0.72)); |
| | ctx.fillStyle = skirtGrad; |
| | ctx.beginPath(); |
| | ctx.moveTo(-bw, tY + 13); ctx.lineTo(-bw - 2.5, tY + 17 + bounce); |
| | ctx.lineTo(bw + 2.5, tY + 17 + bounce); ctx.lineTo(bw, tY + 13); |
| | ctx.closePath(); ctx.fill(); |
| | } |
| | |
| | |
| | const shoulderY = tY + 2; |
| | |
| | const lElbowX = walkAnim ? -tw - 3 + armSwing * 0.3 : -tw - 3; |
| | const lElbowY = walkAnim ? shoulderY + 5 + armSwing * 0.4 : shoulderY + 5; |
| | const rElbowX = walkAnim ? tw + 3 - armSwing * 0.3 : tw + 3; |
| | const rElbowY = walkAnim ? shoulderY + 5 - armSwing * 0.4 : shoulderY + 5; |
| | |
| | const lHandX = walkAnim ? lElbowX - 1.5 + armSwing * 0.4 : lElbowX - 1; |
| | const lHandY = walkAnim ? lElbowY + 4 + armSwing * 0.45 : lElbowY + 4; |
| | const rHandX = walkAnim ? rElbowX + 1.5 - armSwing * 0.4 : rElbowX + 1; |
| | const rHandY = walkAnim ? rElbowY + 4 - armSwing * 0.45 : rElbowY + 4; |
| | |
| | ctx.strokeStyle = skin; ctx.lineWidth = 1.8; ctx.lineCap = 'round'; |
| | |
| | ctx.beginPath(); ctx.moveTo(-tw + 1, shoulderY); |
| | ctx.lineTo(lElbowX, lElbowY); ctx.lineTo(lHandX, lHandY); ctx.stroke(); |
| | |
| | ctx.beginPath(); ctx.moveTo(tw - 1, shoulderY); |
| | ctx.lineTo(rElbowX, rElbowY); ctx.lineTo(rHandX, rHandY); ctx.stroke(); |
| | |
| | ctx.fillStyle = skin; |
| | ctx.beginPath(); ctx.arc(lHandX, lHandY, 1.1, 0, 6.28); ctx.fill(); |
| | ctx.beginPath(); ctx.arc(rHandX, rHandY, 1.1, 0, 6.28); ctx.fill(); |
| | |
| | |
| | ctx.fillStyle = skin; |
| | ctx.fillRect(-1.5, tY - 3.5, 3, 4); |
| | |
| | |
| | const hx = 1, hy = tY - 10; |
| | const hr = 6.5; |
| | |
| | ctx.fillStyle = dim(skin, 0.78); |
| | ctx.beginPath(); ctx.arc(hx + 1.5, hy, hr - 0.5, 0, 6.28); ctx.fill(); |
| | |
| | ctx.fillStyle = skin; |
| | ctx.beginPath(); ctx.arc(hx, hy, hr, 0, 6.28); ctx.fill(); |
| | |
| | ctx.fillStyle = `rgba(255,255,255,0.12)`; |
| | ctx.beginPath(); ctx.arc(hx - 2, hy - 2, hr * 0.55, 0, 6.28); ctx.fill(); |
| | |
| | ctx.fillStyle = dim(skin, 0.88); |
| | ctx.beginPath(); ctx.ellipse(hx + hr - 0.5, hy + 1, 1.5, 2, 0, 0, 6.28); ctx.fill(); |
| | |
| | |
| | ctx.fillStyle = hairColor; |
| | if (gender === 'female') { |
| | |
| | ctx.beginPath(); ctx.arc(hx, hy - 1, hr + 0.5, Math.PI * 1.1, Math.PI * 0.05); ctx.fill(); |
| | ctx.beginPath(); ctx.ellipse(hx - hr + 1, hy + 3, 2.2, hr * 0.85, -0.2, 0, 6.28); ctx.fill(); |
| | ctx.beginPath(); ctx.ellipse(hx + hr - 0.5, hy + 3, 1.8, hr * 0.7, 0.2, 0, 6.28); ctx.fill(); |
| | } else if (gender === 'male') { |
| | |
| | ctx.beginPath(); ctx.arc(hx, hy - 1, hr + 0.5, Math.PI * 1.15, Math.PI * -0.1); ctx.fill(); |
| | ctx.fillRect(hx - hr - 0.5, hy - 1, 3, hr * 0.6); |
| | } else { |
| | |
| | ctx.beginPath(); ctx.arc(hx, hy - 1, hr + 0.5, Math.PI * 1.08, Math.PI * 0.0); ctx.fill(); |
| | ctx.beginPath(); ctx.ellipse(hx - hr + 0.5, hy + 2, 2, hr * 0.65, -0.15, 0, 6.28); ctx.fill(); |
| | } |
| | |
| | |
| | const moodVal = agent.mood || 0; |
| | |
| | |
| | const brBase = hy - 3.4; |
| | const sadTilt = moodVal < 0 ? Math.min(1, -moodVal) * 2.0 : 0; |
| | const happyRaise = moodVal > 0 ? moodVal * 0.8 : 0; |
| | ctx.strokeStyle = hairColor; ctx.lineWidth = 1.0; ctx.lineCap = 'round'; |
| | |
| | ctx.beginPath(); |
| | ctx.moveTo(hx - 3.8, brBase - happyRaise); |
| | ctx.lineTo(hx - 1.0, brBase - happyRaise - sadTilt); |
| | ctx.stroke(); |
| | |
| | ctx.beginPath(); |
| | ctx.moveTo(hx + 1.0, brBase - happyRaise - sadTilt); |
| | ctx.lineTo(hx + 3.8, brBase - happyRaise); |
| | ctx.stroke(); |
| | |
| | |
| | ctx.fillStyle = '#1a1010'; |
| | ctx.beginPath(); ctx.arc(hx - 2.2, hy - 1, 1.1, 0, 6.28); ctx.fill(); |
| | ctx.beginPath(); ctx.arc(hx + 1.5, hy - 1, 1.1, 0, 6.28); ctx.fill(); |
| | |
| | ctx.fillStyle = 'rgba(255,255,255,0.6)'; |
| | ctx.beginPath(); ctx.arc(hx - 1.8, hy - 1.5, 0.45, 0, 6.28); ctx.fill(); |
| | ctx.beginPath(); ctx.arc(hx + 1.9, hy - 1.5, 0.45, 0, 6.28); ctx.fill(); |
| | |
| | |
| | |
| | |
| | |
| | const mouthW = 1.9; |
| | const mCurve = Math.max(-5, Math.min(5, moodVal * 9)); |
| | const mouthY = hy + 3.0; |
| | ctx.strokeStyle = moodVal > 0 ? 'rgba(210,90,90,0.88)' : 'rgba(130,70,80,0.85)'; |
| | ctx.lineWidth = 1.0; ctx.lineCap = 'round'; |
| | ctx.beginPath(); |
| | ctx.moveTo(hx - mouthW, mouthY); |
| | ctx.quadraticCurveTo(hx, mouthY - mCurve, hx + mouthW, mouthY); |
| | ctx.stroke(); |
| | |
| | ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; |
| | |
| | |
| | if (agent.state === 'in_conversation') { |
| | ctx.fillStyle = 'rgba(240,192,64,0.9)'; |
| | ctx.beginPath(); ctx.roundRect(hr + 3, hy - hr - 6, 16, 11, 3); ctx.fill(); |
| | |
| | ctx.beginPath(); ctx.moveTo(hr + 3, hy - hr - 2); ctx.lineTo(hr - 1, hy); ctx.lineTo(hr + 7, hy - hr - 2); ctx.fill(); |
| | ctx.fillStyle = '#1a1a2e'; ctx.font = 'bold 6px Segoe UI'; |
| | ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; |
| | ctx.fillText('...', hr + 11, hy - hr); |
| | } |
| | |
| | if (isSleeping) { |
| | const t = animFrame * 0.04; |
| | ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'left'; |
| | for (let i = 0; i < 3; i++) { |
| | ctx.globalAlpha = 0.3 + i * 0.25; ctx.fillStyle = '#8ab4f8'; |
| | ctx.fillText('z', hr + 2 + i * 4, hy - hr - i * 6 + Math.sin(t + i) * 2); |
| | } |
| | ctx.globalAlpha = 1; |
| | } |
| | |
| | if (agent.partner_id) { |
| | drawHeart(1, hy - hr - 10 + Math.sin(animFrame * 0.04) * 2, 3.5, 'rgba(233,30,144,0.8)'); |
| | } |
| | |
| | |
| | if (isSel) { |
| | ctx.strokeStyle = color; ctx.lineWidth = 1.5; |
| | ctx.setLineDash([3, 3]); |
| | ctx.beginPath(); ctx.arc(hx, hy, hr + 3, 0, 6.28); ctx.stroke(); |
| | ctx.setLineDash([]); |
| | } |
| | |
| | ctx.restore(); |
| | |
| | |
| | const firstName = (agent.name || id).split(' ')[0]; |
| | ctx.font = `${isSel ? 'bold ' : ''}8px Segoe UI`; |
| | ctx.textAlign = 'center'; ctx.textBaseline = 'top'; |
| | ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(firstName, ax + 1, ay + 14 * scale + 1); |
| | ctx.fillStyle = isSel ? '#fff' : '#c8c8d8'; ctx.fillText(firstName, ax, ay + 14 * scale); |
| | |
| | |
| | const moodV = agent.mood || 0; |
| | const mw = 16, mxb = ax - mw / 2, myb = ay + 14 * scale + 11; |
| | ctx.fillStyle = 'rgba(15,52,96,0.45)'; ctx.fillRect(mxb, myb, mw, 2.5); |
| | const mf = (moodV + 1) / 2; |
| | ctx.fillStyle = mf > 0.6 ? '#4ecca3' : (mf > 0.3 ? '#f0c040' : '#e94560'); |
| | ctx.fillRect(mxb, myb, mw * mf, 2.5); |
| | } |
| | |
| | |
| | |
| | |
| | function onCanvasClick(e) { |
| | if (isDragging) return; |
| | if (_blockNextClick) { _blockNextClick = false; return; } |
| | const rect=canvas.getBoundingClientRect(); |
| | const mx=(e.clientX-rect.left)/zoom+panX, my=(e.clientY-rect.top)/zoom+panY; |
| | let clicked=null, minD=24; |
| | for (const [id,pos] of Object.entries(agentPositions)) { |
| | const d=Math.hypot(mx-pos.x,my-pos.y); |
| | if(d<minD){minD=d;clicked=id;} |
| | } |
| | if(clicked){ |
| | selectedAgentId=clicked; |
| | switchTab('agents'); |
| | fetchAgentDetail(clicked); |
| | } else { |
| | selectedAgentId=null; |
| | showDefaultDetail(); |
| | } |
| | } |
| | |
| | function onCanvasMouseMove(e) { |
| | const rect=canvas.getBoundingClientRect(); |
| | const mx=(e.clientX-rect.left)/zoom+panX, my=(e.clientY-rect.top)/zoom+panY; |
| | const W=worldW(), H=worldH(); |
| | const tt=document.getElementById('tooltip'); |
| | |
| | if (isDragging || rectZoomMode) return; |
| | |
| | let foundAgent=null; |
| | for (const [id,pos] of Object.entries(agentPositions)) { |
| | if(Math.hypot(mx-pos.x,my-pos.y)<22){foundAgent=id;break;} |
| | } |
| | |
| | let foundLoc=null; |
| | if (!foundAgent) { |
| | const allPos = getEffectivePositions(); |
| | for (const [id, pos] of Object.entries(allPos)) { |
| | const lx=pos.x*W, ly=pos.y*H; |
| | const hitR = pos.type === 'house' ? 25 : 35; |
| | if(Math.hypot(mx-lx,my-ly)<hitR){foundLoc=id;break;} |
| | } |
| | } |
| | |
| | if(foundAgent!==hoveredAgent){ |
| | hoveredAgent=foundAgent; |
| | canvas.style.cursor=(foundAgent||foundLoc)?'pointer':'default'; |
| | if(foundAgent&&agents[foundAgent]){ |
| | const a=agents[foundAgent]; |
| | let extra=''; |
| | if(a.partner_id&&agents[a.partner_id]) extra=`<br><span style="color:#e91e90">Partner: ${agents[a.partner_id].name}</span>`; |
| | tt.innerHTML=`<b>${a.name}</b><br><span style="color:#a0a0c0">${a.action||'idle'}</span>${extra}`; |
| | tt.style.display='block'; |
| | } else if (!foundLoc) { |
| | tt.style.display='none'; |
| | } |
| | } |
| | |
| | if (!foundAgent && foundLoc && locations[foundLoc]) { |
| | const loc = locations[foundLoc]; |
| | const occ = (loc.occupants||[]); |
| | const occNames = occ.map(o => (typeof o === 'object' ? o.name : (agents[o]?.name || o))).slice(0, 8); |
| | const hasConv = conversationData.active?.some(c => c.location === foundLoc); |
| | tt.innerHTML = `<b>${loc.name||foundLoc}</b> <span style="color:#666">(${loc.zone||''})</span> |
| | ${loc.description ? `<br><span style="color:#888;font-size:10px">${esc(loc.description)}</span>` : ''} |
| | <br><span style="color:#a0a0c0">${occ.length} occupant${occ.length!==1?'s':''}</span> |
| | ${occNames.length > 0 ? `<br><span style="font-size:10px;color:#b0b0c0">${occNames.join(', ')}${occ.length>8?'...':''}</span>` : ''} |
| | ${hasConv ? '<br><span style="color:#f0c040;font-size:10px">Active conversation here</span>' : ''}`; |
| | tt.style.display = 'block'; |
| | canvas.style.cursor = 'pointer'; |
| | } else if (!foundAgent && !foundLoc) { |
| | canvas.style.cursor = 'default'; |
| | if (!hoveredAgent) tt.style.display = 'none'; |
| | } |
| | |
| | if(foundAgent||foundLoc){ |
| | tt.style.left=(e.clientX-rect.left+15)+'px'; |
| | tt.style.top=(e.clientY-rect.top+15)+'px'; |
| | } |
| | } |
| | |
| | |
| | |
| | |
| | function showDefaultDetail() { |
| | const el = document.getElementById('agent-detail'); |
| | const sorted = Object.entries(agents).sort((a,b) => { |
| | const aConv = a[1].state === 'in_conversation' ? 0 : 1; |
| | const bConv = b[1].state === 'in_conversation' ? 0 : 1; |
| | if (aConv !== bConv) return aConv - bConv; |
| | return (a[1].name || '').localeCompare(b[1].name || ''); |
| | }); |
| | |
| | el.innerHTML = ` |
| | <div class="section-header">Population (${Object.keys(agents).length})</div> |
| | ${sorted.map(([aid,a]) => { |
| | const color = AGENT_COLORS[getAgentIdx(aid) % AGENT_COLORS.length]; |
| | const gi = a.gender==='female'?'\uD83D\uDC69':a.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1'; |
| | const stateIcon = a.state==='in_conversation'?' \uD83D\uDCAC':(a.state==='sleeping'?' \uD83D\uDCA4':(a.state==='moving'?' \uD83D\uDEB6':'')); |
| | const partner = a.partner_id && agents[a.partner_id] ? ` \u2764\uFE0F ${agents[a.partner_id].name.split(' ')[0]}` : ''; |
| | const pregIcon = a.pregnant ? ' \uD83E\uDD30' : ''; |
| | const kidIcon = a.children_count > 0 ? ` \uD83D\uDC76${a.children_count}` : ''; |
| | return ` |
| | <div class="agent-list-item" onclick="selectedAgentId='${aid}';fetchAgentDetail('${aid}');"> |
| | <span class="agent-dot" style="background:${color}"></span> |
| | <span style="font-size:13px">${gi}</span> |
| | <div class="agent-info"> |
| | <div class="agent-name" style="color:${color}">${a.name}${partner}${pregIcon}${kidIcon}${stateIcon}</div> |
| | <div class="agent-action">${esc(a.action||'idle')}</div> |
| | </div> |
| | </div>`; |
| | }).join('')}`; |
| | } |
| | |
| | async function fetchAgentDetail(agentId) { |
| | try { |
| | const res=await fetch(`${API_BASE}/agents/${agentId}`); |
| | if(!res.ok) return; |
| | renderAgentDetail(await res.json()); |
| | } catch(e){} |
| | } |
| | |
| | function renderAgentDetail(data) { |
| | const needs=data.needs||{}; |
| | const needBars=[ |
| | {name:'Hunger',key:'hunger',color:'orange'}, |
| | {name:'Energy',key:'energy',color:'blue'}, |
| | {name:'Social',key:'social',color:'purple'}, |
| | {name:'Purpose',key:'purpose',color:'green'}, |
| | {name:'Fun',key:'fun',color:'yellow'}, |
| | ]; |
| | const mood=data.mood||0; |
| | const moodPct=((mood+1)/2*100).toFixed(0); |
| | const moodColor=moodPct>60?'green':(moodPct>30?'yellow':'red'); |
| | const moodLabel=mood>0.3?'Happy':(mood>-0.3?'Okay':'Unhappy'); |
| | const gi=data.gender==='female'?'\uD83D\uDC69':data.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1'; |
| | const memories=(data.recent_memories||[]).slice(-6).reverse(); |
| | const romanceRels=(data.relationships||[]).filter(r=>r.relationship_status&&r.relationship_status!=='none'); |
| | const locName = data.location ? (typeof data.location === 'object' ? data.location.name : data.location) : '?'; |
| | |
| | |
| | const lifeEvents = (data.life_events || []).slice(-10).reverse(); |
| | const evtIcon = { |
| | origin:'\uD83C\uDFE0', career:'\uD83D\uDCBC', dating:'\uD83D\uDC95', engaged:'\uD83D\uDC8D', |
| | married:'\uD83D\uDC92', pregnant:'\uD83E\uDD30', child_born:'\uD83D\uDC76', |
| | promotion:'\uD83D\uDCC8', graduated:'\uD83C\uDF93', achievement:'\u2B50', |
| | milestone:'\uD83C\uDFC6', new_job:'\uD83D\uDCBC', moved:'\uD83D\uDE9A', |
| | breakup:'\uD83D\uDC94', friendship:'\uD83E\uDD1D', |
| | }; |
| | |
| | |
| | const goals = data.goals || []; |
| | const activeGoals = goals.filter(g => g.status === 'active'); |
| | const completedGoals = goals.filter(g => g.status === 'completed'); |
| | |
| | |
| | const children = data.children || []; |
| | const isPregnant = data.pregnant || false; |
| | const pregnantBadge = isPregnant ? ' \uD83E\uDD30' : ''; |
| | const childrenBadge = children.length > 0 ? ` \uD83D\uDC76${children.length}` : ''; |
| | |
| | document.getElementById('agent-detail').innerHTML=` |
| | <div style="margin-bottom:6px"> |
| | <span style="cursor:pointer;color:#888;font-size:11px" onclick="selectedAgentId=null;showDefaultDetail();">← All Agents</span> |
| | </div> |
| | <h2>${gi} ${data.name||'?'}${pregnantBadge}${childrenBadge}</h2> |
| | <p class="subtitle">${data.age||'?'} y/o ${data.occupation||''}</p> |
| | <p class="subtitle" style="color:#888;font-size:10px">${data.traits||''}</p> |
| | <p class="subtitle" style="color:#e0e0e0">@ ${esc(locName)} — ${esc(data.action||'idle')}</p> |
| | |
| | ${romanceRels.length>0?`<div style="margin:6px 0;padding:4px 8px;background:rgba(233,30,144,0.1);border:1px solid rgba(233,30,144,0.2);border-radius:4px;font-size:11px;color:#e91e90"> |
| | ${romanceRels.map(r=>`\u2764 ${r.relationship_status} with ${r.name} (${(r.romantic_interest*100).toFixed(0)}%)`).join('<br>')} |
| | </div>`:''} |
| | |
| | ${children.length>0?`<div style="margin:4px 0;padding:4px 8px;background:rgba(78,204,163,0.08);border:1px solid rgba(78,204,163,0.2);border-radius:4px;font-size:11px;color:#4ecca3"> |
| | \uD83D\uDC76 Children: ${children.map(c=>esc(c)).join(', ')} |
| | </div>`:''} |
| | |
| | <div class="bar-container"> |
| | <div class="bar-label"><span>Mood: ${moodLabel}</span><span>${moodPct}%</span></div> |
| | <div class="bar-bg"><div class="bar-fill ${moodColor}" style="width:${moodPct}%"></div></div> |
| | </div> |
| | |
| | ${needBars.map(n=>{ |
| | const v=(needs[n.key]||0)*100; |
| | return `<div class="bar-container"> |
| | <div class="bar-label"><span>${n.name}</span><span>${v.toFixed(0)}%</span></div> |
| | <div class="bar-bg"><div class="bar-fill ${n.color}" style="width:${v}%"></div></div> |
| | </div>`; |
| | }).join('')} |
| | |
| | <div style="margin-top:6px"> |
| | <div style="font-size:10px;color:#888">Plan: ${esc((data.daily_plan||[]).slice(0,3).join('; ')||'None yet')}</div> |
| | </div> |
| | |
| | ${activeGoals.length>0||completedGoals.length>0?` |
| | <div class="section-header">Goals</div> |
| | ${activeGoals.map(g=>{ |
| | const pct=Math.round((g.progress||0)*100); |
| | return `<div style="margin:3px 0;font-size:11px"> |
| | <div style="display:flex;justify-content:space-between;color:#e0e0f0"> |
| | <span>\uD83C\uDFAF ${esc(g.description)}</span><span style="color:#888;font-size:9px">${pct}%</span> |
| | </div> |
| | <div style="height:3px;background:#1a1a3e;border-radius:2px;margin-top:2px"> |
| | <div style="height:100%;width:${pct}%;background:#4ecca3;border-radius:2px"></div> |
| | </div> |
| | </div>`; |
| | }).join('')} |
| | ${completedGoals.slice(-3).map(g=>`<div style="margin:2px 0;font-size:10px;color:#555">\u2705 ${esc(g.description)}</div>`).join('')} |
| | `:''} |
| | |
| | ${lifeEvents.length>0?` |
| | <div class="section-header">Life History</div> |
| | <div style="max-height:160px;overflow-y:auto"> |
| | ${lifeEvents.map(e=>{ |
| | const icon = evtIcon[e.type] || '\uD83D\uDD39'; |
| | const dayLabel = e.day > 0 ? `Day ${e.day}` : 'Background'; |
| | return `<div style="margin:3px 0;font-size:11px;display:flex;gap:6px;align-items:start"> |
| | <span style="flex-shrink:0">${icon}</span> |
| | <div><span style="color:#555;font-size:9px">${dayLabel}</span><br><span style="color:#c0c0d0">${esc(e.description)}</span></div> |
| | </div>`; |
| | }).join('')} |
| | </div>`:''} |
| | |
| | ${(data.relationships||[]).length>0?` |
| | <div class="section-header">Relationships</div> |
| | ${data.relationships.slice(0,8).map(r=>{ |
| | const fam = (r.familiarity||0)*100; |
| | const trust = (r.trust||0)*100; |
| | const sent = (r.sentiment||0)*100; |
| | const rom = (r.romantic_interest||0)*100; |
| | const statusBadge = r.relationship_status && r.relationship_status !== 'none' |
| | ? `<span style="color:#e91e90;font-size:9px"> \u2764 ${r.relationship_status}</span>` : ''; |
| | return ` |
| | <div class="rel-item" onclick="selectedAgentId='${r.agent_id}';fetchAgentDetail('${r.agent_id}');"> |
| | <div><span class="rel-name">${esc(r.name)}</span>${statusBadge} |
| | <span style="color:#555;font-size:9px">(${r.interaction_count||0} talks)</span></div> |
| | <div class="rel-bars"> |
| | <div class="rel-mini-bar" title="Familiarity ${fam.toFixed(0)}%"><div class="rel-mini-fill" style="width:${fam}%;background:#4ecca3"></div></div> |
| | <div class="rel-mini-bar" title="Trust ${trust.toFixed(0)}%"><div class="rel-mini-fill" style="width:${trust}%;background:#4e9eca"></div></div> |
| | <div class="rel-mini-bar" title="Sentiment ${sent.toFixed(0)}%"><div class="rel-mini-fill" style="width:${sent}%;background:#f0c040"></div></div> |
| | ${rom > 0 ? `<div class="rel-mini-bar" title="Romance ${rom.toFixed(0)}%"><div class="rel-mini-fill" style="width:${rom}%;background:#e91e90"></div></div>` : ''} |
| | </div> |
| | </div>`; |
| | }).join('')}`:''} |
| | |
| | <div class="section-header">Recent Memories</div> |
| | ${memories.map(m=>`<div class="memory-item"><span class="memory-time">${m.time||''}</span> ${esc(m.content||'')}</div>`).join('')||'<div class="memory-item" style="color:#555">No memories yet</div>'} |
| | |
| | ${playerToken && playerAgentId && data.id !== playerAgentId ? ` |
| | <div style="margin-top:10px"> |
| | <button class="btn-primary" style="width:100%" onclick="openChat('${data.id}')">Talk to ${esc(data.name||'them')}</button> |
| | </div>` : ''} |
| | `; |
| | } |
| | |
| | |
| | |
| | |
| | async function fetchConversations() { |
| | try { |
| | const res = await fetch(`${API_BASE}/conversations?include_history=true&limit=15`); |
| | if (!res.ok) return; |
| | conversationData = await res.json(); |
| | renderConversations(); |
| | } catch(e) {} |
| | } |
| | |
| | function renderConversations() { |
| | const panel = document.getElementById('conversations-panel'); |
| | const all = [ |
| | ...(conversationData.active||[]).map(c => ({...c, _active: true})), |
| | ...(conversationData.recent||[]).map(c => ({...c, _active: false})), |
| | ]; |
| | |
| | if (all.length === 0) { |
| | panel.innerHTML = '<div class="conv-empty">No conversations yet.<br>Agents will start talking when they meet!</div>'; |
| | return; |
| | } |
| | |
| | panel.innerHTML = all.map(conv => { |
| | const turns = conv.turns || []; |
| | const badge = conv._active |
| | ? '<span class="conv-badge live">LIVE</span>' |
| | : '<span class="conv-badge ended">ended</span>'; |
| | const names = (conv.participant_names || []).join(' & '); |
| | |
| | return ` |
| | <div class="conv-card ${conv._active ? 'active-conv' : ''}"> |
| | <div class="conv-header"> |
| | <span class="conv-topic">${esc(conv.topic || 'Chat')}</span> |
| | ${badge} |
| | </div> |
| | <div class="conv-participants">${esc(names)} — ${esc(conv.location || '')}</div> |
| | ${turns.slice(-6).map(t => ` |
| | <div class="conv-turn"> |
| | <span class="conv-speaker" style="color:${getAgentColor(t.speaker_id)}">${esc(t.speaker_name)}:</span> |
| | <span class="conv-message">${esc(t.message)}</span> |
| | </div> |
| | `).join('')} |
| | </div>`; |
| | }).join(''); |
| | } |
| | |
| | function getAgentColor(agentId) { |
| | return AGENT_COLORS[getAgentIdx(agentId) % AGENT_COLORS.length]; |
| | } |
| | |
| | |
| | |
| | |
| | function renderEventLog() { |
| | const c = document.getElementById('event-log-panel'); |
| | c.innerHTML = eventLog.slice(-80).map(line => { |
| | let cls = 'event-line'; |
| | if (line.includes('[PLAN]')) cls += ' plan'; |
| | else if (line.includes('[CONV]')) cls += ' conv'; |
| | else if (line.includes('[ROMANCE]')) cls += ' romance'; |
| | else if (line.includes('[MOVE]')) cls += ' move'; |
| | else if (line.includes('[REFLECT]')) cls += ' reflect'; |
| | else if (line.includes('[EVENT]') || line.includes('[ENTROPY]')) cls += ' event'; |
| | else if (line.startsWith('---') || line.startsWith('\n---')) cls += ' time'; |
| | return `<div class="${cls}">${esc(line)}</div>`; |
| | }).join(''); |
| | c.scrollTop = c.scrollHeight; |
| | } |
| | |
| | function esc(s) { return s ? s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') : ''; } |
| | |
| | |
| | |
| | |
| | let lastTick = -1; |
| | let ws = null; |
| | let wsRetryTimer = null; |
| | |
| | function processStateData(data) { |
| | const clock = data.clock || {}; |
| | currentTimeOfDay = clock.time_of_day || 'morning'; |
| | currentWeather = (data.weather || 'sunny').toLowerCase(); |
| | |
| | const dayNum = clock.day || 1; |
| | const dayOfWeek = ((dayNum - 1) % 7) + 1; |
| | const isWeekend = dayOfWeek >= 6; |
| | document.getElementById('clock').textContent = `Day ${dayNum}, ${clock.time_str||'??:??'} (${currentTimeOfDay})`; |
| | const badge = document.getElementById('weekend-badge'); |
| | badge.style.display = isWeekend ? 'flex' : 'none'; |
| | document.getElementById('weather-icon').textContent = WEATHER_ICONS[currentWeather] || '\u2600\uFE0F'; |
| | document.getElementById('weather').textContent = currentWeather; |
| | document.getElementById('agent-count').innerHTML = `<span class="dot green"></span> ${Object.keys(data.agents||{}).length} agents`; |
| | document.getElementById('conv-count').textContent = `${data.active_conversations||0} convos`; |
| | |
| | const usage = data.llm_usage || ''; |
| | const cm = usage.match(/calls:\s*(\d+)/i), $m = usage.match(/\$([0-9.]+)/); |
| | document.getElementById('api-calls').textContent = `API: ${cm?cm[1]:'0'}`; |
| | document.getElementById('cost').textContent = $m ? `$${$m[1]}` : '$0.00'; |
| | |
| | if (data.llm_provider && data.llm_model) { |
| | |
| | let label = data.llm_model |
| | .replace(/-\d{8}$/, '') |
| | .replace(/-instant$/, '') |
| | .replace(/^gemini-/, ''); |
| | const providerIcon = { nn: '🧠', gemini: '✦', groq: '⚡', claude: '◆', ollama: '🦙' }; |
| | const icon = providerIcon[data.llm_provider] || '⚡'; |
| | |
| | |
| | const status = data.llm_status; |
| | const isNoKey = status === 'nokey'; |
| | const isLimited = status === 'limited'; |
| | const isSkipped = data.llm_skipped === true; |
| | const hasCalls = (data.llm_calls_last_tick || 0) > 0; |
| | let dotColor, statusTip; |
| | if (isNoKey) { dotColor = '#f0a040'; statusTip = 'no API token — set HF_TOKEN env var'; } |
| | else if (isLimited) { dotColor = '#e94560'; statusTip = 'quota / rate limit hit'; } |
| | else if (isSkipped) { dotColor = '#666'; statusTip = 'LLM skipped (fast mode)'; } |
| | else if (hasCalls) { dotColor = '#4ecca3'; statusTip = `${data.llm_calls_last_tick} calls this tick`; } |
| | else { dotColor = '#f0c040'; statusTip = 'idle — no calls needed'; } |
| | |
| | const calls = data.llm_calls_last_tick || 0; |
| | const callBadge = calls > 0 ? ` <span style="font-size:10px;color:#4ecca3;opacity:0.85">×${calls}</span>` : ''; |
| | const el = document.getElementById('llm-model'); |
| | el.innerHTML = `${icon} ${label}${callBadge} <span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${dotColor};vertical-align:middle;margin-left:2px"></span>`; |
| | el.title = `${data.llm_provider}: ${data.llm_model} — ${statusTip}`; |
| | } |
| | |
| | agents = data.agents || {}; |
| | renderPlayerPanel(); |
| | |
| | const tick = clock.total_ticks || 0; |
| | if (tick !== lastTick) fetchSecondaryData(); |
| | lastTick = tick; |
| | |
| | if (activeTab === 'agents') { |
| | if (selectedAgentId) fetchAgentDetail(selectedAgentId); |
| | else showDefaultDetail(); |
| | } |
| | } |
| | |
| | async function fetchSecondaryData() { |
| | try { |
| | const locRes = await fetch(`${API_BASE}/city/locations`); |
| | if (locRes.ok) locations = await locRes.json(); |
| | } catch(e) {} |
| | |
| | try { |
| | const er = await fetch(`${API_BASE}/events`); |
| | if (er.ok) { |
| | const d2 = await er.json(); |
| | eventLog = (d2.events||[]).map(e => e.message||'').filter(m => m.trim()); |
| | checkForNotableEvents(); |
| | if (activeTab === 'events') renderEventLog(); |
| | } |
| | } catch(e) {} |
| | |
| | if (activeTab === 'conversations') { |
| | fetchConversations(); |
| | } else { |
| | try { |
| | const cr = await fetch(`${API_BASE}/conversations?include_history=false`); |
| | if (cr.ok) { |
| | const cd = await cr.json(); |
| | conversationData.active = cd.active || []; |
| | } |
| | } catch(e) {} |
| | } |
| | } |
| | |
| | function connectWebSocket() { |
| | const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| | const wsUrl = `${wsProto}//${window.location.host}/ws/stream`; |
| | ws = new WebSocket(wsUrl); |
| | |
| | ws.onopen = () => { |
| | connected = true; |
| | document.getElementById('status').innerHTML = '<span class="dot green"></span> Live (WS)'; |
| | }; |
| | |
| | ws.onmessage = (evt) => { |
| | try { |
| | const msg = JSON.parse(evt.data); |
| | if (msg.type === 'tick' && msg.state) { |
| | processStateData(msg.state); |
| | } else if (msg.type === 'event') { |
| | eventLog.push(msg.message); |
| | if (eventLog.length > 200) eventLog = eventLog.slice(-200); |
| | checkForNotableEvents(); |
| | if (activeTab === 'events') renderEventLog(); |
| | } |
| | } catch(e) {} |
| | }; |
| | |
| | ws.onclose = () => { |
| | connected = false; |
| | document.getElementById('status').innerHTML = '<span class="dot red"></span> Disconnected'; |
| | wsRetryTimer = setTimeout(connectWebSocket, 3000); |
| | }; |
| | |
| | ws.onerror = () => { ws.close(); }; |
| | } |
| | |
| | async function fetchState() { |
| | try { |
| | const res = await fetch(`${API_BASE}/city`); |
| | if (!res.ok) throw new Error(); |
| | const data = await res.json(); |
| | if (!connected) { |
| | connected = true; |
| | document.getElementById('status').innerHTML = '<span class="dot green"></span> Connected'; |
| | } |
| | processStateData(data); |
| | } catch(e) { |
| | if (connected) { |
| | connected = false; |
| | document.getElementById('status').innerHTML = '<span class="dot red"></span> Disconnected'; |
| | } |
| | } |
| | } |
| | |
| | |
| | |
| | |
| | let simPaused = false; |
| | let simSpeed = 1.0; |
| | let llmCallProbability = 0.10; |
| | |
| | async function togglePause() { |
| | try { |
| | const endpoint = simPaused ? 'resume' : 'pause'; |
| | const res = await fetch(`${API_BASE}/controls/${endpoint}`, { method: 'POST' }); |
| | if (res.ok) { |
| | const data = await res.json(); |
| | simPaused = data.paused; |
| | updateControlsUI(); |
| | } |
| | } catch(e) {} |
| | } |
| | |
| | async function setSpeed(mult) { |
| | try { |
| | const res = await fetch(`${API_BASE}/controls/speed?multiplier=${mult}`, { method: 'POST' }); |
| | if (res.ok) { |
| | const data = await res.json(); |
| | simSpeed = data.speed; |
| | updateControlsUI(); |
| | } |
| | } catch(e) {} |
| | } |
| | |
| | function updateControlsUI() { |
| | const pauseBtn = document.getElementById('btn-pause'); |
| | pauseBtn.className = 'ctrl-btn' + (simPaused ? ' paused' : ''); |
| | pauseBtn.title = simPaused ? 'Resume' : 'Pause'; |
| | |
| | |
| | const speedBtns = [ |
| | { id: 'btn-slow', min: 2.0, max: Infinity }, |
| | { id: 'btn-1x', min: 0.8, max: 2.0 }, |
| | { id: 'btn-2x', min: 0.35, max: 0.8 }, |
| | { id: 'btn-5x', min: 0.15, max: 0.35 }, |
| | { id: 'btn-10x', min: 0.05, max: 0.15 }, |
| | { id: 'btn-50x', min: 0, max: 0.05 }, |
| | ]; |
| | for (const btn of speedBtns) { |
| | const el = document.getElementById(btn.id); |
| | if (el) el.className = 'ctrl-btn' + (simSpeed >= btn.min && simSpeed < btn.max ? ' active' : ''); |
| | } |
| | |
| | |
| | let label = 'PAUSED'; |
| | if (!simPaused) { |
| | if (simSpeed >= 2.0) label = '0.3x'; |
| | else if (simSpeed >= 0.8) label = '1x'; |
| | else if (simSpeed >= 0.35) label = '2x'; |
| | else if (simSpeed >= 0.15) label = '5x'; |
| | else if (simSpeed >= 0.05) label = '10x'; |
| | else label = '50x'; |
| | } |
| | document.getElementById('speed-label').textContent = label; |
| | } |
| | |
| | async function onLlmProbSlider(val) { |
| | const pct = parseInt(val); |
| | document.getElementById('llm-prob-label').textContent = pct + '%'; |
| | llmCallProbability = pct / 100; |
| | try { |
| | await fetch(`${API_BASE}/controls/llm_probability?value=${llmCallProbability}`, { method: 'POST' }); |
| | } catch(e) {} |
| | } |
| | |
| | function updateLlmProbUI(prob) { |
| | llmCallProbability = prob; |
| | const pct = Math.round(prob * 100); |
| | const slider = document.getElementById('llm-prob-slider'); |
| | const label = document.getElementById('llm-prob-label'); |
| | if (slider) slider.value = pct; |
| | if (label) label.textContent = pct + '%'; |
| | } |
| | |
| | async function fetchControls() { |
| | try { |
| | const res = await fetch(`${API_BASE}/controls`); |
| | if (res.ok) { |
| | const data = await res.json(); |
| | simPaused = data.paused; |
| | simSpeed = data.speed; |
| | if (data.llm_call_probability !== undefined) updateLlmProbUI(data.llm_call_probability); |
| | updateControlsUI(); |
| | } |
| | } catch(e) {} |
| | } |
| | |
| | |
| | |
| | |
| | let lastEventCount = 0; |
| | |
| | function showToast(message, type='info') { |
| | const container = document.getElementById('toast-container'); |
| | const toast = document.createElement('div'); |
| | toast.className = 'toast ' + type; |
| | toast.textContent = message; |
| | container.appendChild(toast); |
| | setTimeout(() => toast.remove(), 5000); |
| | while (container.children.length > 5) container.firstChild.remove(); |
| | } |
| | |
| | function checkForNotableEvents() { |
| | if (eventLog.length <= lastEventCount) return; |
| | const newEvents = eventLog.slice(lastEventCount); |
| | lastEventCount = eventLog.length; |
| | |
| | for (const msg of newEvents) { |
| | if (msg.includes('[ROMANCE]')) showToast(msg.replace(/\s*\[ROMANCE\]\s*/, ''), 'romance'); |
| | else if (msg.includes('[EVENT]') && !msg.includes('Weather')) showToast(msg.replace(/\s*\[EVENT\]\s*/, ''), 'event'); |
| | else if (msg.includes('[GOSSIP]')) showToast(msg.replace(/\s*\[GOSSIP\]\s*/, ''), 'gossip'); |
| | else if (msg.includes('[CONV]') && msg.includes('starts talking')) showToast(msg.replace(/\s*\[CONV\]\s*/, ''), 'conv'); |
| | } |
| | } |
| | |
| | |
| | |
| | |
| | let playerToken = localStorage.getItem('soci_token') || null; |
| | let playerUsername = localStorage.getItem('soci_username') || null; |
| | let playerAgentId = localStorage.getItem('soci_agent_id') || null; |
| | let chatTargetId = null; |
| | let chatHistory = []; |
| | let authMode = 'login'; |
| | |
| | function closeLoginModal() { |
| | document.getElementById('login-modal').style.display = 'none'; |
| | } |
| | |
| | function toggleAuthMode() { |
| | authMode = authMode === 'login' ? 'register' : 'login'; |
| | document.getElementById('auth-title').textContent = authMode === 'login' ? 'Welcome to Soci City' : 'Create Account'; |
| | document.getElementById('auth-btn').textContent = authMode === 'login' ? 'Login' : 'Register'; |
| | document.getElementById('auth-switch-text').innerHTML = authMode === 'login' |
| | ? 'No account? <a onclick="toggleAuthMode()">Register</a>' |
| | : 'Have an account? <a onclick="toggleAuthMode()">Login</a>'; |
| | document.getElementById('auth-error').style.display = 'none'; |
| | } |
| | |
| | async function authSubmit() { |
| | const username = document.getElementById('auth-username').value.trim(); |
| | const password = document.getElementById('auth-password').value; |
| | const errEl = document.getElementById('auth-error'); |
| | if (!username || !password) { errEl.textContent = 'Please fill in both fields'; errEl.style.display = 'block'; return; } |
| | const endpoint = authMode === 'login' ? '/api/auth/login' : '/api/auth/register'; |
| | try { |
| | const res = await fetch(window.location.origin + endpoint, { |
| | method: 'POST', headers: {'Content-Type':'application/json'}, |
| | body: JSON.stringify({username, password}) |
| | }); |
| | const data = await res.json(); |
| | if (!res.ok) { errEl.textContent = data.detail || 'Error'; errEl.style.display = 'block'; return; } |
| | playerToken = data.token; |
| | playerUsername = data.username; |
| | playerAgentId = data.agent_id || null; |
| | localStorage.setItem('soci_token', playerToken); |
| | localStorage.setItem('soci_username', playerUsername); |
| | if (playerAgentId) localStorage.setItem('soci_agent_id', playerAgentId); |
| | closeLoginModal(); |
| | renderPlayerPanel(); |
| | } catch(e) { errEl.textContent = 'Connection error'; errEl.style.display = 'block'; } |
| | } |
| | |
| | async function authLogout() { |
| | if (playerToken) { |
| | try { await fetch(window.location.origin + '/api/auth/logout', {method:'POST', headers:{'Authorization':'Bearer '+playerToken}}); } catch(e){} |
| | } |
| | playerToken = playerUsername = playerAgentId = null; |
| | localStorage.removeItem('soci_token'); localStorage.removeItem('soci_username'); localStorage.removeItem('soci_agent_id'); |
| | document.getElementById('player-panel').style.display = 'none'; |
| | document.getElementById('chat-panel') && (document.getElementById('chat-panel').style.display = 'none'); |
| | } |
| | |
| | function renderPlayerPanel() { |
| | const panel = document.getElementById('player-panel'); |
| | if (!playerToken) { panel.style.display = 'none'; return; } |
| | panel.style.display = 'block'; |
| | const agent = playerAgentId ? agents[playerAgentId] : null; |
| | document.getElementById('pp-name').textContent = agent ? agent.name : (playerUsername || 'You'); |
| | const locName = agent ? (locations[agent.location]?.name || agent.location || '') : ''; |
| | document.getElementById('pp-loc').textContent = locName ? `@ ${locName}` : ''; |
| | |
| | |
| | const sel = document.getElementById('pp-move-select'); |
| | const locKeys = Object.keys(locations); |
| | if (locKeys.length > 0 && sel.options.length === 0) { |
| | sel.innerHTML = locKeys.map(lid => { |
| | const ln = locations[lid]?.name || lid; |
| | const selected = agent && agent.location === lid ? ' selected' : ''; |
| | return `<option value="${lid}"${selected}>${ln}</option>`; |
| | }).join(''); |
| | } |
| | } |
| | |
| | async function playerMove() { |
| | if (!playerToken || !playerAgentId) return; |
| | const location = document.getElementById('pp-move-select').value; |
| | try { |
| | const res = await fetch(window.location.origin + '/api/player/move', { |
| | method:'POST', headers:{'Content-Type':'application/json'}, |
| | body: JSON.stringify({token: playerToken, location}) |
| | }); |
| | if (res.ok) renderPlayerPanel(); |
| | } catch(e){} |
| | } |
| | |
| | async function openProfileEditor() { |
| | if (!playerAgentId) return; |
| | |
| | try { |
| | const res = await fetch(`${API_BASE}/agents/${playerAgentId}`); |
| | if (res.ok) { |
| | const data = await res.json(); |
| | document.getElementById('pe-name').value = data.name || ''; |
| | document.getElementById('pe-age').value = data.age || 30; |
| | document.getElementById('pe-occupation').value = data.occupation || ''; |
| | document.getElementById('pe-gender').value = data.gender || 'unknown'; |
| | document.getElementById('pe-background').value = data.background || ''; |
| | const pers = data.personality || {}; |
| | const extVal = pers.extraversion || 5; |
| | const agrVal = pers.agreeableness || 7; |
| | const opnVal = pers.openness || 6; |
| | document.getElementById('pe-extraversion').value = extVal; |
| | document.getElementById('pe-extra-val').textContent = extVal; |
| | document.getElementById('pe-agreeableness').value = agrVal; |
| | document.getElementById('pe-agree-val').textContent = agrVal; |
| | document.getElementById('pe-openness').value = opnVal; |
| | document.getElementById('pe-open-val').textContent = opnVal; |
| | } |
| | } catch(e) { |
| | |
| | const agent = agents[playerAgentId]; |
| | if (agent) { |
| | document.getElementById('pe-name').value = agent.name || ''; |
| | document.getElementById('pe-age').value = agent.age || 30; |
| | document.getElementById('pe-occupation').value = agent.occupation || ''; |
| | document.getElementById('pe-gender').value = agent.gender || 'unknown'; |
| | } |
| | } |
| | document.getElementById('profile-modal').style.display = 'flex'; |
| | } |
| | |
| | async function saveProfile() { |
| | if (!playerToken) return; |
| | const body = { |
| | token: playerToken, |
| | name: document.getElementById('pe-name').value.trim(), |
| | age: parseInt(document.getElementById('pe-age').value)||30, |
| | gender: document.getElementById('pe-gender').value, |
| | occupation: document.getElementById('pe-occupation').value.trim(), |
| | background: document.getElementById('pe-background').value.trim(), |
| | extraversion: parseInt(document.getElementById('pe-extraversion').value), |
| | agreeableness: parseInt(document.getElementById('pe-agreeableness').value), |
| | openness: parseInt(document.getElementById('pe-openness').value), |
| | }; |
| | try { |
| | const res = await fetch(window.location.origin + '/api/player/update', { |
| | method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) |
| | }); |
| | if (res.ok) { document.getElementById('profile-modal').style.display='none'; renderPlayerPanel(); } |
| | } catch(e){} |
| | } |
| | |
| | function openPlansModal() { |
| | if (!playerAgentId) return; |
| | const agent = agents[playerAgentId]; |
| | const plans = agent?.daily_plan || []; |
| | const listEl = document.getElementById('plans-list'); |
| | listEl.innerHTML = plans.length > 0 |
| | ? plans.map((p,i) => `<div style="padding:3px 0;border-bottom:1px solid #1a3a6e">${i+1}. ${esc(p)}</div>`).join('') |
| | : '<div style="color:#555">No plans yet.</div>'; |
| | document.getElementById('plans-modal').style.display = 'flex'; |
| | } |
| | |
| | async function addPlan() { |
| | if (!playerToken) return; |
| | const input = document.getElementById('plans-input'); |
| | const item = input.value.trim(); |
| | if (!item) return; |
| | try { |
| | const res = await fetch(window.location.origin + '/api/player/plan', { |
| | method:'POST', headers:{'Content-Type':'application/json'}, |
| | body: JSON.stringify({token: playerToken, plan_item: item}) |
| | }); |
| | if (res.ok) { input.value = ''; openPlansModal(); } |
| | } catch(e){} |
| | } |
| | |
| | |
| | let stockexMarketData = {}; |
| | |
| | async function openStockExModal() { |
| | if (!playerToken) return; |
| | document.getElementById('stockex-modal').style.display = 'flex'; |
| | document.getElementById('sx-result').innerHTML = ''; |
| | |
| | try { |
| | const res = await fetch(`${API_BASE}/stockex/market`); |
| | if (res.ok) { |
| | stockexMarketData = await res.json(); |
| | const sel = document.getElementById('sx-symbol'); |
| | const symbols = Object.keys(stockexMarketData).sort(); |
| | sel.innerHTML = symbols.map(s => `<option value="${s}">${s}</option>`).join(''); |
| | sel.value = 'EXAE'; |
| | updateStockExPrice(); |
| | } |
| | } catch(e) { |
| | document.getElementById('sx-market-info').textContent = 'Could not load market data'; |
| | } |
| | loadStockExPortfolio(); |
| | } |
| | |
| | function updateStockExPrice() { |
| | const sym = document.getElementById('sx-symbol').value; |
| | const mkt = stockexMarketData[sym]; |
| | if (mkt) { |
| | const bid = mkt.bid_price || 0; |
| | const ask = mkt.ask_price || 0; |
| | const mid = mkt.mid || ((bid + ask) / 2); |
| | document.getElementById('sx-price').value = mid.toFixed(2); |
| | document.getElementById('sx-market-info').textContent = |
| | `Bid: ${bid.toFixed(2)} | Ask: ${ask.toFixed(2)} | Mid: ${mid.toFixed(2)}`; |
| | } |
| | } |
| | |
| | async function loadStockExPortfolio() { |
| | const mid = document.getElementById('sx-member').value.trim().toUpperCase(); |
| | if (!mid) return; |
| | try { |
| | const res = await fetch(`${API_BASE}/stockex/portfolio/${mid}`); |
| | if (res.ok) { |
| | const data = await res.json(); |
| | const cap = data.capital || 0; |
| | const pnl = data.pnl || 0; |
| | const pnlColor = pnl >= 0 ? '#4ecca3' : '#e74c3c'; |
| | let html = `<div style="margin-bottom:4px"><b>${data.member_id}</b> Capital: <b>${cap.toFixed(2)}</b> | P&L: <span style="color:${pnlColor}">${pnl.toFixed(2)}</span></div>`; |
| | const holdings = data.holdings || []; |
| | if (holdings.length > 0) { |
| | html += '<div style="display:flex;flex-wrap:wrap;gap:4px">'; |
| | for (const h of holdings) { |
| | const pnlH = h.unrealized_pnl || 0; |
| | const c = pnlH >= 0 ? '#4ecca3' : '#e74c3c'; |
| | html += `<span style="padding:1px 4px;background:#1a1a3e;border-radius:3px;font-size:10px">${h.symbol}: ${h.quantity} <span style="color:${c}">(${pnlH > 0 ? '+' : ''}${pnlH.toFixed(0)})</span></span>`; |
| | } |
| | html += '</div>'; |
| | } |
| | document.getElementById('stockex-portfolio').innerHTML = html; |
| | document.getElementById('stockex-status').textContent = `${holdings.length} positions | Obligation: ${data.obligation || 20}/day`; |
| | } else { |
| | document.getElementById('stockex-portfolio').innerHTML = '<span style="color:#e74c3c">Member not found</span>'; |
| | } |
| | } catch(e) { |
| | document.getElementById('stockex-portfolio').innerHTML = '<span style="color:#e74c3c">Could not load portfolio</span>'; |
| | } |
| | } |
| | |
| | async function submitStockExOrder() { |
| | if (!playerToken) return; |
| | const resultEl = document.getElementById('sx-result'); |
| | const body = { |
| | token: playerToken, |
| | member_id: document.getElementById('sx-member').value.trim().toUpperCase(), |
| | symbol: document.getElementById('sx-symbol').value, |
| | side: document.getElementById('sx-side').value, |
| | quantity: parseInt(document.getElementById('sx-qty').value) || 0, |
| | price: parseFloat(document.getElementById('sx-price').value) || 0, |
| | }; |
| | if (body.quantity <= 0 || body.price <= 0) { |
| | resultEl.innerHTML = '<span style="color:#e74c3c">Invalid quantity or price</span>'; |
| | return; |
| | } |
| | resultEl.innerHTML = '<span style="color:#888">Placing order...</span>'; |
| | try { |
| | const res = await fetch(`${API_BASE}/stockex/order`, { |
| | method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) |
| | }); |
| | const data = await res.json(); |
| | if (res.ok) { |
| | resultEl.innerHTML = `<span style="color:#4ecca3">${esc(data.detail)} — ${data.cl_ord_id}</span>`; |
| | loadStockExPortfolio(); |
| | } else { |
| | resultEl.innerHTML = `<span style="color:#e74c3c">${esc(data.detail || data.error || 'Order failed')}</span>`; |
| | } |
| | } catch(e) { |
| | resultEl.innerHTML = '<span style="color:#e74c3c">Connection error</span>'; |
| | } |
| | } |
| | |
| | |
| | document.getElementById('sx-symbol')?.addEventListener('change', updateStockExPrice); |
| | document.getElementById('sx-member')?.addEventListener('change', loadStockExPortfolio); |
| | |
| | |
| | function openChat(targetId) { |
| | chatTargetId = targetId; |
| | chatHistory = []; |
| | const target = agents[targetId]; |
| | let chatPanel = document.getElementById('chat-panel'); |
| | if (!chatPanel) { |
| | chatPanel = document.createElement('div'); |
| | chatPanel.id = 'chat-panel'; |
| | document.getElementById('agent-detail').after(chatPanel); |
| | } |
| | chatPanel.style.display = 'block'; |
| | chatPanel.innerHTML = ` |
| | <div class="chat-header"> |
| | <span>Talking to ${esc(target?.name || targetId)}</span> |
| | <span style="cursor:pointer;color:#888" onclick="closeChat()">✕ Close</span> |
| | </div> |
| | <div id="chat-messages"></div> |
| | <div id="chat-input-row"> |
| | <input id="chat-input" type="text" placeholder="Say something..." onkeydown="if(event.key==='Enter')sendChat()"> |
| | <button id="chat-send" onclick="sendChat()">Send</button> |
| | </div>`; |
| | } |
| | |
| | function closeChat() { |
| | chatTargetId = null; |
| | const p = document.getElementById('chat-panel'); |
| | if (p) p.style.display = 'none'; |
| | } |
| | |
| | function appendChatMsg(cls, speaker, text) { |
| | const el = document.getElementById('chat-messages'); |
| | if (!el) return; |
| | const div = document.createElement('div'); |
| | div.className = cls; |
| | div.innerHTML = `<b>${esc(speaker)}:</b> ${esc(text)}`; |
| | el.appendChild(div); |
| | el.scrollTop = el.scrollHeight; |
| | } |
| | |
| | async function sendChat() { |
| | if (!playerToken || !playerAgentId || !chatTargetId) return; |
| | const input = document.getElementById('chat-input'); |
| | const message = input.value.trim(); |
| | if (!message) return; |
| | input.value = ''; |
| | input.disabled = true; |
| | document.getElementById('chat-send').disabled = true; |
| | appendChatMsg('chat-msg-you', playerUsername || 'You', message); |
| | appendChatMsg('chat-msg-thinking', agents[chatTargetId]?.name || '...', '...'); |
| | try { |
| | const res = await fetch(window.location.origin + '/api/player/talk', { |
| | method:'POST', headers:{'Content-Type':'application/json'}, |
| | body: JSON.stringify({token: playerToken, target_id: chatTargetId, message}) |
| | }); |
| | const el = document.getElementById('chat-messages'); |
| | if (el && el.lastChild) el.lastChild.remove(); |
| | if (res.ok) { |
| | const data = await res.json(); |
| | appendChatMsg('chat-msg-npc', agents[chatTargetId]?.name || chatTargetId, data.reply || '(no reply)'); |
| | } else { |
| | appendChatMsg('chat-msg-thinking', 'System', 'Could not get a response right now.'); |
| | } |
| | } catch(e) { |
| | const el = document.getElementById('chat-messages'); |
| | if (el && el.lastChild) el.lastChild.remove(); |
| | appendChatMsg('chat-msg-thinking', 'System', 'Connection error.'); |
| | } |
| | input.disabled = false; |
| | document.getElementById('chat-send').disabled = false; |
| | input.focus(); |
| | } |
| | |
| | async function checkSession() { |
| | if (!playerToken) { document.getElementById('login-modal').style.display = 'flex'; return; } |
| | try { |
| | const res = await fetch(window.location.origin + '/api/auth/me', {headers:{'Authorization':'Bearer '+playerToken}}); |
| | if (res.ok) { |
| | const data = await res.json(); |
| | playerUsername = data.username; |
| | playerAgentId = data.agent_id; |
| | if (playerAgentId) localStorage.setItem('soci_agent_id', playerAgentId); |
| | renderPlayerPanel(); |
| | } else { |
| | |
| | playerToken = null; localStorage.removeItem('soci_token'); |
| | document.getElementById('login-modal').style.display = 'flex'; |
| | } |
| | } catch(e) { renderPlayerPanel(); } |
| | } |
| | |
| | |
| | |
| | |
| | let _llmPopupOpen = false; |
| | |
| | document.getElementById('llm-model').addEventListener('click', async (e) => { |
| | e.stopPropagation(); |
| | const popup = document.getElementById('llm-popup'); |
| | if (_llmPopupOpen) { popup.style.display = 'none'; _llmPopupOpen = false; return; } |
| | |
| | |
| | const rect = e.currentTarget.getBoundingClientRect(); |
| | popup.style.left = rect.left + 'px'; |
| | popup.style.top = (rect.bottom + 6) + 'px'; |
| | |
| | |
| | try { |
| | const [res, quotaRes] = await Promise.all([ |
| | fetch(`${API_BASE}/llm/providers`), |
| | fetch(`${API_BASE}/llm/quota`).catch(() => null), |
| | ]); |
| | const data = await res.json(); |
| | const quota = quotaRes && quotaRes.ok ? await quotaRes.json() : null; |
| | const existing = popup.querySelectorAll('.llm-opt,.llm-quota-panel'); |
| | existing.forEach(el => el.remove()); |
| | |
| | |
| | |
| | function estimateRuntime(q) { |
| | if (!q || q.remaining <= 0) return 'exhausted'; |
| | const callsPerHour = q.max_calls_per_hour || (q.rpm || 4) * 60; |
| | if (callsPerHour <= 0) return '∞'; |
| | const hours = q.remaining / callsPerHour; |
| | if (hours >= 48) return `~${Math.round(hours / 24)}d`; |
| | if (hours >= 1) return `~${hours.toFixed(1)}h`; |
| | return `~${Math.round(hours * 60)}min`; |
| | } |
| | |
| | const isRateLimited = (id) => id === 'gemini' || id === 'groq'; |
| | |
| | for (const p of data.providers) { |
| | const isActive = p.id === data.current && (p.model === data.current_model || (!p.model && p.id !== 'hf')); |
| | const row = document.createElement('div'); |
| | row.className = 'llm-opt' + (isActive ? ' active' : ''); |
| | |
| | |
| | let badge = ''; |
| | const pQuota = (quota && quota.providers && quota.providers[p.id]) || null; |
| | if (isRateLimited(p.id) && pQuota) { |
| | const isLimited = pQuota.status === 'limited'; |
| | const rpm = pQuota.rpm || 4; |
| | if (isLimited) { |
| | badge = `<span style="font-size:10px;color:#e94560;margin-left:auto">exhausted</span>`; |
| | } else { |
| | const rt = estimateRuntime({remaining: pQuota.daily_limit, rpm, max_calls_per_hour: rpm * 60}); |
| | badge = `<span style="font-size:10px;color:#8899aa;margin-left:auto">${pQuota.daily_limit}/day · ${rt}</span>`; |
| | } |
| | } |
| | |
| | row.innerHTML = `<span class="llm-check">${isActive ? '✔' : ''}</span> |
| | <span style="font-size:15px">${p.icon}</span> |
| | <span>${p.label}</span>${badge}`; |
| | |
| | if (isRateLimited(p.id) && !isActive) { |
| | |
| | row.addEventListener('click', (ev) => { |
| | ev.stopPropagation(); |
| | |
| | popup.querySelectorAll('.llm-quota-panel').forEach(el => el.remove()); |
| | const panel = document.createElement('div'); |
| | panel.className = 'llm-quota-panel'; |
| | panel.style.cssText = 'padding:8px 14px;background:#0a1628;border-top:1px solid #0f3460;border-bottom:1px solid #0f3460;font-size:11px;color:#c8c8d8'; |
| | |
| | |
| | panel.addEventListener('click', (e) => e.stopPropagation()); |
| | panel.addEventListener('mousedown', (e) => e.stopPropagation()); |
| | |
| | |
| | const pq = (quota && quota.providers && quota.providers[p.id]) || null; |
| | const lim = pq ? (pq.daily_limit || 0) : 0; |
| | const rpm = (pq && pq.rpm) || 4; |
| | const maxPerHour = (pq && pq.max_calls_per_hour) || (rpm * 60); |
| | |
| | const pqForCalc = {remaining: lim, rpm: rpm, max_calls_per_hour: maxPerHour}; |
| | const isExhausted = pq && pq.status === 'limited'; |
| | |
| | if (isExhausted) { |
| | panel.innerHTML = `<div style="color:#e94560;margin-bottom:6px">Quota exhausted. Resets daily (10:00 AM).</div>`; |
| | row.after(panel); |
| | return; |
| | } |
| | |
| | const runtime = estimateRuntime(pqForCalc); |
| | const numAgents = quota.num_agents || 50; |
| | |
| | const defaultProb = 10; |
| | |
| | |
| | function usageStats(probPct) { |
| | const prob = probPct / 100; |
| | |
| | const idleFraction = 0.4; |
| | const eligibleAgents = Math.round(numAgents * idleFraction); |
| | |
| | const attemptsPerTick = eligibleAgents * prob + prob; |
| | |
| | const callsPerTick = Math.min(attemptsPerTick, 2); |
| | |
| | const ticksH = 900; |
| | const callsPerHour = Math.min(callsPerTick * ticksH, rpm * 60); |
| | |
| | const llmPct = Math.min(prob * 100, 100); |
| | return { callsPerHour: Math.round(callsPerHour), llmPct: Math.round(llmPct) }; |
| | } |
| | |
| | const initStats = usageStats(defaultProb); |
| | |
| | panel.innerHTML = |
| | `<div style="color:#4ecca3;font-weight:600;margin-bottom:4px">${p.icon} ${p.label}</div>` + |
| | `<div style="margin-bottom:4px">Daily limit: <b>${lim}</b> requests · ${rpm} req/min</div>` + |
| | `<div style="display:flex;align-items:center;gap:8px;margin-top:6px">` + |
| | `<label style="font-size:11px;color:#8899aa">Probability:</label>` + |
| | `<input type="range" min="1" max="100" value="${defaultProb}" style="flex:1;accent-color:#4ecca3" class="popup-prob-slider">` + |
| | `<span class="popup-prob-val" style="font-size:12px;color:#4ecca3;min-width:32px">${defaultProb}%</span>` + |
| | `</div>` + |
| | `<div class="popup-usage-info" style="font-size:10px;color:#8899aa;margin:4px 0 8px 0;line-height:1.5">` + |
| | `<span style="color:#c8c8d8">${initStats.llmPct}%</span> of decisions via LLM · ` + |
| | `<span style="color:#c8c8d8">~${initStats.callsPerHour}</span> calls/hour` + |
| | `</div>` + |
| | `<button class="popup-switch-btn" style="width:100%;padding:6px;border:none;border-radius:4px;` + |
| | `background:#4ecca3;color:#0a0a23;font-weight:600;cursor:pointer;font-size:12px">` + |
| | `Switch to ${p.label} at ${defaultProb}%</button>`; |
| | |
| | row.after(panel); |
| | |
| | |
| | const slider = panel.querySelector('.popup-prob-slider'); |
| | const valLabel = panel.querySelector('.popup-prob-val'); |
| | const usageInfo = panel.querySelector('.popup-usage-info'); |
| | const btn = panel.querySelector('.popup-switch-btn'); |
| | |
| | slider.addEventListener('input', (e) => { |
| | e.stopPropagation(); |
| | const pv = parseInt(slider.value); |
| | valLabel.textContent = pv + '%'; |
| | const stats = usageStats(pv); |
| | usageInfo.innerHTML = |
| | `<span style="color:#c8c8d8">${stats.llmPct}%</span> of decisions via LLM · ` + |
| | `<span style="color:#c8c8d8">~${stats.callsPerHour}</span> calls/hour`; |
| | btn.textContent = `Switch to ${p.label} at ${pv}%`; |
| | }); |
| | |
| | btn.addEventListener('click', async (ev2) => { |
| | ev2.stopPropagation(); |
| | const probVal = parseInt(slider.value) / 100; |
| | popup.style.display = 'none'; _llmPopupOpen = false; |
| | try { |
| | |
| | await fetch(`${API_BASE}/controls/llm_probability?value=${probVal}`, {method: 'POST'}); |
| | |
| | const body = {provider: p.id}; |
| | if (p.model) body.model = p.model; |
| | const r = await fetch(`${API_BASE}/llm/provider`, { |
| | method: 'POST', |
| | headers: {'Content-Type': 'application/json'}, |
| | body: JSON.stringify(body), |
| | }); |
| | if (!r.ok) { const err = await r.json(); showToast(`LLM switch failed: ${err.detail}`, 'event'); return; } |
| | |
| | updateLlmProbUI(probVal); |
| | showToast(`Switched to ${p.label} at ${Math.round(probVal*100)}% · ${runtime}`, 'conv'); |
| | } catch (err) { showToast('LLM switch error', 'event'); } |
| | }); |
| | }); |
| | } else if (!isActive) { |
| | |
| | row.addEventListener('click', async () => { |
| | popup.style.display = 'none'; _llmPopupOpen = false; |
| | try { |
| | |
| | const autoProb = (p.id === 'nn') ? 1.0 : 0.10; |
| | await fetch(`${API_BASE}/controls/llm_probability?value=${autoProb}`, {method: 'POST'}); |
| | const body = {provider: p.id}; |
| | if (p.model) body.model = p.model; |
| | const r = await fetch(`${API_BASE}/llm/provider`, { |
| | method: 'POST', |
| | headers: {'Content-Type': 'application/json'}, |
| | body: JSON.stringify(body), |
| | }); |
| | if (!r.ok) { const err = await r.json(); showToast(`LLM switch failed: ${err.detail}`, 'event'); return; } |
| | updateLlmProbUI(autoProb); |
| | showToast(`Switched to ${p.label} at ${Math.round(autoProb*100)}%`, 'conv'); |
| | } catch (err) { showToast('LLM switch error', 'event'); } |
| | }); |
| | } |
| | popup.appendChild(row); |
| | } |
| | |
| | const sep = document.createElement('div'); |
| | sep.style.cssText = 'border-top:1px solid #0f3460;margin:4px 0'; |
| | popup.appendChild(sep); |
| | const testRow = document.createElement('div'); |
| | testRow.className = 'llm-opt'; |
| | testRow.innerHTML = `<span class="llm-check"></span><span style="font-size:15px">🔬</span><span>Test current LLM…</span>`; |
| | testRow.addEventListener('click', async (ev) => { |
| | ev.stopPropagation(); |
| | popup.style.display = 'none'; _llmPopupOpen = false; |
| | showToast('Testing LLM…', 'event'); |
| | try { |
| | const r = await fetch(`${API_BASE}/llm/test`); |
| | const d = await r.json(); |
| | if (d.ok) { |
| | showToast(`✔ LLM OK — "${d.raw.slice(0,60)}"`, 'conv'); |
| | } else { |
| | const msg = (d.error || d.raw || 'empty response — model may not be on HF serverless API').slice(0, 120); |
| | showToast(`✘ ${d.model}: ${msg}`, 'event'); |
| | } |
| | } catch (err) { showToast('Test request failed', 'event'); } |
| | }); |
| | popup.appendChild(testRow); |
| | popup.style.display = 'block'; |
| | _llmPopupOpen = true; |
| | } catch { showToast('Could not fetch providers', 'event'); } |
| | }); |
| | |
| | document.addEventListener('click', () => { |
| | if (_llmPopupOpen) { |
| | document.getElementById('llm-popup').style.display = 'none'; |
| | _llmPopupOpen = false; |
| | } |
| | }); |
| | |
| | |
| | |
| | |
| | initCanvas(); |
| | showDefaultDetail(); |
| | fetchState(); |
| | fetchControls(); |
| | connectWebSocket(); |
| | setInterval(() => { if (!ws || ws.readyState !== WebSocket.OPEN) fetchState(); }, POLL_INTERVAL); |
| | checkSession(); |
| | </script> |
| | </body> |
| | </html> |
| |
|