RayMelius Claude Opus 4.6 commited on
Commit
4d6af04
·
1 Parent(s): be3b4ef

First-person VR mode, calendar system, agent fixes, player character

Browse files

- First-person mode (WASD+mouse) with PointerLockControls
- WebXR VR support for headsets
- Player can join city as playable character with HUD
- NPCs face the player when nearby in FP mode
- Calendar system starting 1 April 2026 with seasons
- Weather: 75% sunny, snow only in winter, rain other seasons
- Aging tied to calendar (1 year = 365 days, not tick-based)
- Moon phases (full/half/quarter/new, 28-day cycle)
- Agents always visible (no indoor hiding)
- Windows semi-transparent in interior view
- Dead agents show death date/age, no needs bars
- Agent movement speed 4x faster
- Sleeping agents lie along beds (not perpendicular)
- Furniture marked isFurniture for interior view

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

Files changed (1) hide show
  1. web/3d.html +421 -96
web/3d.html CHANGED
@@ -106,6 +106,35 @@
106
  .ctrl-btn:hover { background: rgba(30,30,50,0.85); color: #fff; }
107
  .ctrl-btn.active { background: rgba(78,204,163,0.3); border-color: #4ecca3; color: #4ecca3; }
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  #loading {
110
  position: fixed; inset: 0;
111
  display: flex; flex-direction: column;
@@ -153,6 +182,33 @@
153
  <button class="ctrl-btn" onclick="toggleTopDown()" title="Top-down view">&#9650;</button>
154
  <button class="ctrl-btn" onclick="zoomIn()" title="Zoom in">+</button>
155
  <button class="ctrl-btn" onclick="zoomOut()" title="Zoom out">&minus;</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  </div>
157
 
158
  <script type="importmap">
@@ -167,6 +223,7 @@
167
  <script type="module">
168
  import * as THREE from 'three';
169
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 
170
 
171
  // ============================================================
172
  // CONSTANTS
@@ -660,25 +717,13 @@ function createHouse(id, locData) {
660
  group.add(win);
661
  }
662
 
663
- // Bed (visible when house is transparent at night)
664
- const bed = new THREE.Mesh(
665
- new THREE.BoxGeometry(1.2, 0.15, 0.7),
666
- mat(0x8b6040)
667
- );
668
- bed.position.set(0.6, 0.08, 0);
669
- group.add(bed);
670
- const mattress = new THREE.Mesh(
671
- new THREE.BoxGeometry(1.1, 0.1, 0.6),
672
- mat(0xe8e0d0)
673
- );
674
- mattress.position.set(0.6, 0.18, 0);
675
- group.add(mattress);
676
- const pillow = new THREE.Mesh(
677
- new THREE.BoxGeometry(0.25, 0.08, 0.35),
678
- mat(0xf0f0f0)
679
- );
680
- pillow.position.set(1.1, 0.24, 0);
681
- group.add(pillow);
682
 
683
  const label = createLabel(locData.label, group, wallH + roofH + 1);
684
  const badge = createOccupantBadge(group, wallH + roofH);
@@ -720,14 +765,12 @@ function createApartment(id, locData) {
720
  }
721
  }
722
 
723
- // Beds inside (visible at night through transparent walls)
724
  for (let bx = -1; bx <= 1; bx += 2) {
725
  const bed = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.12, 0.5), mat(0x8b6040));
726
- bed.position.set(bx * 1.2, 0.06, 0);
727
- group.add(bed);
728
  const matt = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.08, 0.45), mat(0xe0d8c8));
729
- matt.position.set(bx * 1.2, 0.14, 0);
730
- group.add(matt);
731
  }
732
 
733
  const label = createLabel(locData.label, group, wallH + 2);
@@ -1387,38 +1430,40 @@ function addFurniture(id, group, locData) {
1387
  const chairMat = mat(0x6a5030);
1388
  const deskMat = mat(0xa09080);
1389
 
 
 
1390
  if (FOOD_LOCS.has(id)) {
1391
  for (let i = -1; i <= 1; i++) {
1392
  const table = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.4, 0.7), tableMat);
1393
  table.position.set(i * 1.2, 0.2, 0);
1394
- group.add(table);
1395
  for (let s of [-0.5, 0.5]) {
1396
  const chair = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.25, 0.3), chairMat);
1397
  chair.position.set(i * 1.2 + s, 0.125, 0.5);
1398
- group.add(chair);
1399
  const back = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.3, 0.05), chairMat);
1400
  back.position.set(i * 1.2 + s, 0.35, 0.65);
1401
- group.add(back);
1402
  }
1403
  }
1404
  } else if (DESK_LOCS.has(id)) {
1405
  for (let i = -1; i <= 1; i += 2) {
1406
  const desk = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.45, 0.6), deskMat);
1407
  desk.position.set(i * 1.3, 0.225, 0);
1408
- group.add(desk);
1409
  const ch = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.25, 0.3), chairMat);
1410
  ch.position.set(i * 1.3, 0.125, 0.5);
1411
- group.add(ch);
1412
  }
1413
  } else if (SEAT_LOCS.has(id)) {
1414
  for (let row = 0; row < 2; row++) {
1415
  for (let col = -1; col <= 1; col++) {
1416
  const seat = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.25, 0.35), chairMat);
1417
  seat.position.set(col * 0.7, 0.125, -0.5 + row * 0.8);
1418
- group.add(seat);
1419
  const sb = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.35, 0.05), chairMat);
1420
  sb.position.set(col * 0.7, 0.35, -0.5 + row * 0.8 + 0.18);
1421
- group.add(sb);
1422
  }
1423
  }
1424
  }
@@ -1641,13 +1686,19 @@ sunSprite.scale.set(14, 14, 1);
1641
  sunSprite.renderOrder = -1;
1642
  scene.add(sunSprite);
1643
 
1644
- // Moon
1645
  const moonGeo = new THREE.SphereGeometry(4, 32, 32);
1646
  const moonMat = new THREE.MeshBasicMaterial({ color: 0xeeeeff, transparent: true, opacity: 0.95 });
1647
  const moonMesh = new THREE.Mesh(moonGeo, moonMat);
1648
  moonMesh.renderOrder = -1;
1649
  moonMesh.visible = false;
1650
  scene.add(moonMesh);
 
 
 
 
 
 
1651
  const moonGlow = new THREE.Sprite(
1652
  new THREE.SpriteMaterial({ map: celestialTex(0xaabbee), transparent: true, depthTest: false })
1653
  );
@@ -1655,6 +1706,19 @@ moonGlow.scale.set(22, 22, 1);
1655
  moonGlow.renderOrder = -2;
1656
  moonGlow.visible = false;
1657
  scene.add(moonGlow);
 
 
 
 
 
 
 
 
 
 
 
 
 
1658
 
1659
  // Stars
1660
  const starCount = 500;
@@ -1841,8 +1905,10 @@ function updateCelestials(hour) {
1841
  moonMesh.visible = true;
1842
  moonGlow.position.set(mx, my, mz - 1);
1843
  moonGlow.visible = true;
 
1844
  } else {
1845
  moonMesh.visible = false;
 
1846
  moonGlow.visible = false;
1847
  }
1848
  starPoints.visible = hour >= 18 || hour <= 5;
@@ -2282,20 +2348,18 @@ function enterInterior(buildingId) {
2282
  const bldg = buildingMeshes.get(buildingId);
2283
  if (!bldg) return;
2284
  bldg.traverse(child => {
2285
- if (child.isMesh && !child.userData?.isWindow && !child.userData?.isDoor) {
2286
- child.userData._savedOpacity = child.material.opacity;
2287
- child.userData._savedTransparent = child.material.transparent;
2288
- child.userData._savedDepthWrite = child.material.depthWrite;
2289
- child.material.transparent = true;
2290
- child.material.opacity = 0.12;
 
 
 
 
2291
  child.material.depthWrite = false;
2292
  }
2293
- if (child.isMesh && child.userData?.isWindow) {
2294
- child.userData._savedOpacity = child.material.opacity;
2295
- child.userData._savedTransparent = child.material.transparent;
2296
- child.material.transparent = true;
2297
- child.material.opacity = 0.15;
2298
- }
2299
  });
2300
  smoothZoomTo(bldg.position, 8);
2301
  }
@@ -2438,21 +2502,28 @@ function updateAgentInfo(agentId, data) {
2438
  html += `<div class="info-section">`;
2439
  html += `<h4>Status</h4>`;
2440
  html += `<div class="info-row"><span class="label">Location</span><span class="value">${data.location_name || data.location || '?'}</span></div>`;
2441
- html += `<div class="info-row"><span class="label">State</span><span class="value">${data.state || '?'}</span></div>`;
2442
- html += `<div class="info-row"><span class="label">Action</span><span class="value">${data.current_action || data.action || data.state || '?'}</span></div>`;
2443
- if (data.mood !== undefined) {
2444
- html += `<div class="info-row"><span class="label">Mood</span><span class="value">${typeof data.mood === 'number' ? (data.mood * 100).toFixed(0) + '%' : data.mood}</span></div>`;
 
 
 
 
 
 
 
2445
  }
2446
- if (data.age !== undefined) {
2447
  html += `<div class="info-row"><span class="label">Age</span><span class="value">${data.age}</span></div>`;
2448
  }
2449
  if (data.gender) {
2450
  html += `<div class="info-row"><span class="label">Gender</span><span class="value">${data.gender}</span></div>`;
2451
  }
2452
- if (data.occupation) {
2453
  html += `<div class="info-row"><span class="label">Occupation</span><span class="value">${data.occupation}</span></div>`;
2454
  }
2455
- if (data.lifePhase) {
2456
  html += `<div class="info-row"><span class="label">Life Phase</span><span class="value">${data.lifePhase}</span></div>`;
2457
  }
2458
  html += `</div>`;
@@ -2468,9 +2539,9 @@ function updateAgentInfo(agentId, data) {
2468
  html += `</div>`;
2469
  }
2470
 
2471
- // Needs
2472
  const needs = data.needs || {};
2473
- if (Object.keys(needs).length > 0) {
2474
  html += `<div class="info-section"><h4>Needs</h4>`;
2475
  for (const [need, val] of Object.entries(needs)) {
2476
  const pct = typeof val === 'number' ? val * 100 : parseFloat(val) * 100;
@@ -2597,13 +2668,19 @@ controls.addEventListener('change', updateDetailLevel);
2597
  const clock = new THREE.Clock();
2598
  let frameCount = 0;
2599
 
 
2600
  function animate() {
2601
- requestAnimationFrame(animate);
2602
  const dt = clock.getDelta();
2603
  frameCount++;
2604
 
2605
- controls.update();
2606
- updateCameraAnimation();
 
 
 
 
 
 
2607
 
2608
  // Smooth agent movement
2609
  for (const [agentId, mesh] of agentMeshes) {
@@ -2612,7 +2689,7 @@ function animate() {
2612
  const dx = target.x - mesh.position.x;
2613
  const dz = target.z - mesh.position.z;
2614
  const dist0 = Math.sqrt(dx * dx + dz * dz);
2615
- const speed = 0.03;
2616
 
2617
  if (dist0 > speed) {
2618
  let mx = (dx / dist0) * speed;
@@ -2675,11 +2752,7 @@ function animate() {
2675
  }
2676
  }
2677
 
2678
- // Agent visibility: hide inside buildings unless interior view is active
2679
- const agentLoc2 = mesh.userData.data?.location || '';
2680
- const locInfo2 = LOCATION_POSITIONS[agentLoc2] || dynamicLocations[agentLoc2];
2681
- const isOutdoorLoc = OUTDOOR_LOCS.has(agentLoc2) || locInfo2?.type === 'park' || locInfo2?.type === 'square' || locInfo2?.type === 'sports';
2682
- mesh.visible = isOutdoorLoc || agentLoc2 === interiorBuildingId;
2683
 
2684
  // Sleeping, sitting, walking, or idle animation
2685
  const agentState = mesh.userData.data?.state || '';
@@ -2693,13 +2766,13 @@ function animate() {
2693
  const isSitting = !moving && atLocation && SITTING_LOCS.has(agentLoc);
2694
 
2695
  if (isSleeping) {
2696
- mesh.rotation.x = Math.PI / 2;
2697
- mesh.rotation.z = 0;
2698
- mesh.position.y = 0.35;
2699
- if (mesh.userData.leftLeg) mesh.userData.leftLeg.rotation.x = 0.15;
2700
- if (mesh.userData.rightLeg) mesh.userData.rightLeg.rotation.x = -0.08;
2701
- if (mesh.userData.leftArm) mesh.userData.leftArm.rotation.x = 0.6;
2702
- if (mesh.userData.rightArm) mesh.userData.rightArm.rotation.x = 0.4;
2703
  } else if (isSitting) {
2704
  mesh.rotation.x = 0;
2705
  mesh.position.y = 0.0;
@@ -2790,7 +2863,7 @@ function animate() {
2790
  if (cloud.position.x > 70) cloud.position.x = -70;
2791
  }
2792
 
2793
- renderer.render(scene, camera);
2794
  }
2795
 
2796
  // ============================================================
@@ -2807,15 +2880,228 @@ window.addEventListener('resize', () => {
2807
  renderer.setSize(w, h);
2808
  });
2809
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2810
  // ============================================================
2811
  // KEYBOARD
2812
  // ============================================================
2813
  document.addEventListener('keydown', (e) => {
2814
- if (e.key === 'Escape') closeInfoPanel();
 
 
 
 
 
 
 
 
2815
  if (e.key === 'r' || e.key === 'R') resetCamera();
2816
  if (e.key === '+' || e.key === '=') zoomIn();
2817
  if (e.key === '-') zoomOut();
2818
  });
 
 
 
 
 
 
2819
 
2820
  // ============================================================
2821
  // FALLBACK: REST polling if WebSocket isn't available
@@ -2849,7 +3135,6 @@ if (isEmbedded) {
2849
  }
2850
  connectWebSocket();
2851
  setInterval(pollFallback, 3000);
2852
- animate();
2853
  updateDetailLevel();
2854
 
2855
  let demoSpeedMultiplier = 1.0;
@@ -2940,16 +3225,67 @@ function spawnDemoAgents() {
2940
 
2941
  let demoDay = 1;
2942
  let demoMinute = 630;
2943
- const DEMO_WEATHER_CYCLE = ['sunny','sunny','sunny','cloudy','cloudy','rainy','stormy','rainy','cloudy','sunny','sunny','snowy','cloudy','sunny','foggy','sunny'];
2944
- let demoWIdx = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2945
  let tickCount = 0;
2946
  const deadAgents = [];
2947
 
 
2948
  function demoTimeStr() {
2949
  const hh = Math.floor(demoMinute / 60);
2950
  const mm = demoMinute % 60;
2951
  const phase = hh >= 5 && hh < 7 ? 'dawn' : hh >= 7 && hh < 12 ? 'morning' : hh >= 12 && hh < 17 ? 'afternoon' : hh >= 17 && hh < 20 ? 'evening' : 'night';
2952
- return `Day ${demoDay}, ${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')} (${phase})`;
2953
  }
2954
 
2955
  function getLifePlan(id) {
@@ -2969,21 +3305,7 @@ function spawnDemoAgents() {
2969
  const aliveIds = allIds.filter(id => agentLife[id]?.alive);
2970
  tickCount++;
2971
 
2972
- // Age all agents every 72 ticks (~1 sim-day 72 ticks of 20min → 1 year per ~3 real-min cycles)
2973
- if (tickCount % 6 === 0) {
2974
- for (const id of aliveIds) {
2975
- agentLife[id].age++;
2976
- demoAgents[id].age = agentLife[id].age;
2977
- const a = agentLife[id].age;
2978
- if (a === 3) { agentLife[id].lifePhase = 'kindergarten'; agentLife[id].occupation = 'Child'; }
2979
- if (a === 6) { agentLife[id].lifePhase = 'school'; agentLife[id].occupation = 'Student'; agentMemories[id].push('Started school'); }
2980
- if (a === 18) { agentLife[id].lifePhase = 'university'; agentLife[id].occupation = 'Student'; agentMemories[id].push('Graduated high school'); }
2981
- if (a === 23) { agentLife[id].lifePhase = 'working'; agentLife[id].occupation = OCCUPATIONS[hash(id) % OCCUPATIONS.length]; agentMemories[id].push(`Started career as ${agentLife[id].occupation}`); }
2982
- if (a === 65) { agentLife[id].lifePhase = 'retired'; agentMemories[id].push('Retired'); }
2983
- demoAgents[id].occupation = agentLife[id].occupation;
2984
- demoAgents[id].lifePhase = agentLife[id].lifePhase;
2985
- }
2986
- }
2987
 
2988
  // Marriage: every 4 ticks, try to match single adults
2989
  if (tickCount % 4 === 0) {
@@ -3061,8 +3383,11 @@ function spawnDemoAgents() {
3061
  agentLife[id].alive = false;
3062
  demoAgents[id].location = 'cemetery';
3063
  demoAgents[id].state = 'deceased';
 
 
 
3064
  deadAgents.push({ id, name: demoAgents[id].name, age: a, day: demoDay });
3065
- agentMemories[id].push(`Passed away at age ${a} (Day ${demoDay})`);
3066
  if (agentLife[id].partner) {
3067
  const p = agentLife[id].partner;
3068
  agentLife[p].partner = null;
@@ -3087,7 +3412,7 @@ function spawnDemoAgents() {
3087
 
3088
  handleStateUpdate({
3089
  type: 'tick', time: demoTimeStr(),
3090
- state: { agents: demoAgents, locations: {}, weather: DEMO_WEATHER_CYCLE[0] }
3091
  });
3092
  document.getElementById('sim-agents').textContent = `${Object.keys(demoAgents).length} agents (demo)`;
3093
 
@@ -3097,13 +3422,13 @@ function spawnDemoAgents() {
3097
  if (wsConnected || demoPaused) return;
3098
 
3099
  demoMinute += 20;
3100
- if (demoMinute >= 1440) { demoMinute -= 1440; demoDay++; }
3101
  const hh = Math.floor(demoMinute / 60);
3102
- if (demoMinute % 60 === 0 && hh % 3 === 0) {
3103
- demoWIdx = (demoWIdx + 1) % DEMO_WEATHER_CYCLE.length;
3104
  }
3105
 
3106
- const w = DEMO_WEATHER_CYCLE[demoWIdx];
3107
  const badW = w === 'rainy' || w === 'stormy' || w === 'snowy';
3108
  const isNightTime = hh >= 22 || hh < 6;
3109
  const isLateEvening = hh >= 20 && hh < 22;
 
106
  .ctrl-btn:hover { background: rgba(30,30,50,0.85); color: #fff; }
107
  .ctrl-btn.active { background: rgba(78,204,163,0.3); border-color: #4ecca3; color: #4ecca3; }
108
 
109
+ #player-login {
110
+ position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
111
+ background: rgba(10,10,18,0.95); border: 1px solid rgba(78,204,163,0.4);
112
+ border-radius: 12px; padding: 24px 32px; z-index: 300; display: none;
113
+ backdrop-filter: blur(16px); min-width: 280px; text-align: center;
114
+ }
115
+ #player-login h2 { color: #4ecca3; margin: 0 0 16px; font-size: 18px; font-weight: 400; letter-spacing: 2px; }
116
+ #player-login input {
117
+ width: 100%; padding: 8px 12px; margin: 6px 0; border: 1px solid rgba(255,255,255,0.15);
118
+ border-radius: 6px; background: rgba(255,255,255,0.06); color: #e0e0e8; font-size: 14px;
119
+ box-sizing: border-box;
120
+ }
121
+ #player-login select {
122
+ width: 100%; padding: 8px 12px; margin: 6px 0; border: 1px solid rgba(255,255,255,0.15);
123
+ border-radius: 6px; background: rgba(20,20,35,0.9); color: #e0e0e8; font-size: 14px;
124
+ }
125
+ #player-login button {
126
+ margin-top: 12px; padding: 8px 24px; border: none; border-radius: 6px;
127
+ background: #4ecca3; color: #0a0a12; font-size: 14px; cursor: pointer; font-weight: 600;
128
+ }
129
+ #player-login button:hover { background: #6be0b8; }
130
+ #player-hud {
131
+ position: fixed; top: 50px; left: 12px; background: rgba(10,10,18,0.8);
132
+ border: 1px solid rgba(78,204,163,0.3); border-radius: 8px; padding: 10px 14px;
133
+ z-index: 60; display: none; font-size: 12px; color: #ccc; min-width: 160px;
134
+ backdrop-filter: blur(8px);
135
+ }
136
+ #player-hud .phud-name { color: #4ecca3; font-size: 14px; font-weight: 500; margin-bottom: 6px; }
137
+
138
  #loading {
139
  position: fixed; inset: 0;
140
  display: flex; flex-direction: column;
 
182
  <button class="ctrl-btn" onclick="toggleTopDown()" title="Top-down view">&#9650;</button>
183
  <button class="ctrl-btn" onclick="zoomIn()" title="Zoom in">+</button>
184
  <button class="ctrl-btn" onclick="zoomOut()" title="Zoom out">&minus;</button>
185
+ <button class="ctrl-btn" id="btn-fp" onclick="window._toggleFP()" title="First-person view">&#128065;</button>
186
+ <button class="ctrl-btn" id="btn-vr" style="display:none" title="Enter VR">VR</button>
187
+ <button class="ctrl-btn" id="btn-join" onclick="window._showJoin()" title="Join as player">JOIN</button>
188
+ </div>
189
+ <div id="fp-crosshair" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);pointer-events:none;z-index:50">
190
+ <svg width="24" height="24"><circle cx="12" cy="12" r="3" fill="none" stroke="rgba(255,255,255,0.6)" stroke-width="1"/><line x1="12" y1="4" x2="12" y2="8" stroke="rgba(255,255,255,0.4)" stroke-width="1"/><line x1="12" y1="16" x2="12" y2="20" stroke="rgba(255,255,255,0.4)" stroke-width="1"/><line x1="4" y1="12" x2="8" y2="12" stroke="rgba(255,255,255,0.4)" stroke-width="1"/><line x1="16" y1="12" x2="20" y2="12" stroke="rgba(255,255,255,0.4)" stroke-width="1"/></svg>
191
+ </div>
192
+ <div id="fp-hint" style="display:none;position:fixed;bottom:60px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.7);color:#fff;padding:8px 16px;border-radius:8px;font-size:13px;z-index:50;text-align:center">
193
+ WASD — move &middot; Mouse — look &middot; ESC — exit &middot; E — interact
194
+ </div>
195
+
196
+ <div id="player-login">
197
+ <h2>JOIN SOCI CITY</h2>
198
+ <input id="player-name" type="text" placeholder="Your name..." maxlength="20">
199
+ <select id="player-gender"><option value="male">Male</option><option value="female">Female</option></select>
200
+ <select id="player-age">
201
+ <option value="20">20</option><option value="25">25</option><option value="30">30</option>
202
+ <option value="35">35</option><option value="40">40</option>
203
+ </select>
204
+ <br>
205
+ <button onclick="window._joinCity()">Enter City</button>
206
+ <button onclick="document.getElementById('player-login').style.display='none'" style="background:transparent;color:#888;border:1px solid rgba(255,255,255,0.15);margin-left:8px">Cancel</button>
207
+ </div>
208
+
209
+ <div id="player-hud">
210
+ <div class="phud-name" id="phud-name"></div>
211
+ <div id="phud-stats"></div>
212
  </div>
213
 
214
  <script type="importmap">
 
223
  <script type="module">
224
  import * as THREE from 'three';
225
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
226
+ import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
227
 
228
  // ============================================================
229
  // CONSTANTS
 
717
  group.add(win);
718
  }
719
 
720
+ // Bed
721
+ const bed = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.15, 0.7), mat(0x8b6040));
722
+ bed.position.set(0.6, 0.08, 0); bed.userData.isFurniture = true; group.add(bed);
723
+ const mattress = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.1, 0.6), mat(0xe8e0d0));
724
+ mattress.position.set(0.6, 0.18, 0); mattress.userData.isFurniture = true; group.add(mattress);
725
+ const pillow = new THREE.Mesh(new THREE.BoxGeometry(0.25, 0.08, 0.35), mat(0xf0f0f0));
726
+ pillow.position.set(1.1, 0.24, 0); pillow.userData.isFurniture = true; group.add(pillow);
 
 
 
 
 
 
 
 
 
 
 
 
727
 
728
  const label = createLabel(locData.label, group, wallH + roofH + 1);
729
  const badge = createOccupantBadge(group, wallH + roofH);
 
765
  }
766
  }
767
 
768
+ // Beds inside
769
  for (let bx = -1; bx <= 1; bx += 2) {
770
  const bed = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.12, 0.5), mat(0x8b6040));
771
+ bed.position.set(bx * 1.2, 0.06, 0); bed.userData.isFurniture = true; group.add(bed);
 
772
  const matt = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.08, 0.45), mat(0xe0d8c8));
773
+ matt.position.set(bx * 1.2, 0.14, 0); matt.userData.isFurniture = true; group.add(matt);
 
774
  }
775
 
776
  const label = createLabel(locData.label, group, wallH + 2);
 
1430
  const chairMat = mat(0x6a5030);
1431
  const deskMat = mat(0xa09080);
1432
 
1433
+ function addF(mesh) { mesh.userData.isFurniture = true; group.add(mesh); }
1434
+
1435
  if (FOOD_LOCS.has(id)) {
1436
  for (let i = -1; i <= 1; i++) {
1437
  const table = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.4, 0.7), tableMat);
1438
  table.position.set(i * 1.2, 0.2, 0);
1439
+ addF(table);
1440
  for (let s of [-0.5, 0.5]) {
1441
  const chair = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.25, 0.3), chairMat);
1442
  chair.position.set(i * 1.2 + s, 0.125, 0.5);
1443
+ addF(chair);
1444
  const back = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.3, 0.05), chairMat);
1445
  back.position.set(i * 1.2 + s, 0.35, 0.65);
1446
+ addF(back);
1447
  }
1448
  }
1449
  } else if (DESK_LOCS.has(id)) {
1450
  for (let i = -1; i <= 1; i += 2) {
1451
  const desk = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.45, 0.6), deskMat);
1452
  desk.position.set(i * 1.3, 0.225, 0);
1453
+ addF(desk);
1454
  const ch = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.25, 0.3), chairMat);
1455
  ch.position.set(i * 1.3, 0.125, 0.5);
1456
+ addF(ch);
1457
  }
1458
  } else if (SEAT_LOCS.has(id)) {
1459
  for (let row = 0; row < 2; row++) {
1460
  for (let col = -1; col <= 1; col++) {
1461
  const seat = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.25, 0.35), chairMat);
1462
  seat.position.set(col * 0.7, 0.125, -0.5 + row * 0.8);
1463
+ addF(seat);
1464
  const sb = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.35, 0.05), chairMat);
1465
  sb.position.set(col * 0.7, 0.35, -0.5 + row * 0.8 + 0.18);
1466
+ addF(sb);
1467
  }
1468
  }
1469
  }
 
1686
  sunSprite.renderOrder = -1;
1687
  scene.add(sunSprite);
1688
 
1689
+ // Moon with phases
1690
  const moonGeo = new THREE.SphereGeometry(4, 32, 32);
1691
  const moonMat = new THREE.MeshBasicMaterial({ color: 0xeeeeff, transparent: true, opacity: 0.95 });
1692
  const moonMesh = new THREE.Mesh(moonGeo, moonMat);
1693
  moonMesh.renderOrder = -1;
1694
  moonMesh.visible = false;
1695
  scene.add(moonMesh);
1696
+ const moonShadowGeo = new THREE.SphereGeometry(4.05, 32, 32);
1697
+ const moonShadowMat = new THREE.MeshBasicMaterial({ color: 0x0e1530, transparent: true, opacity: 0.92 });
1698
+ const moonShadow = new THREE.Mesh(moonShadowGeo, moonShadowMat);
1699
+ moonShadow.renderOrder = -0.5;
1700
+ moonShadow.visible = false;
1701
+ scene.add(moonShadow);
1702
  const moonGlow = new THREE.Sprite(
1703
  new THREE.SpriteMaterial({ map: celestialTex(0xaabbee), transparent: true, depthTest: false })
1704
  );
 
1706
  moonGlow.renderOrder = -2;
1707
  moonGlow.visible = false;
1708
  scene.add(moonGlow);
1709
+ let moonPhaseDay = 0;
1710
+ function getMoonPhase(day) {
1711
+ const phase = (day % 28) / 28;
1712
+ return phase;
1713
+ }
1714
+ function updateMoonPhase(day) {
1715
+ const phase = getMoonPhase(day);
1716
+ const offset = Math.cos(phase * Math.PI * 2) * 4.5;
1717
+ moonShadow.position.copy(moonMesh.position);
1718
+ moonShadow.position.x += offset;
1719
+ moonShadow.visible = moonMesh.visible;
1720
+ moonGlow.material.opacity = moonMesh.visible ? (0.15 + 0.45 * (0.5 + 0.5 * Math.cos(phase * Math.PI * 2))) : 0;
1721
+ }
1722
 
1723
  // Stars
1724
  const starCount = 500;
 
1905
  moonMesh.visible = true;
1906
  moonGlow.position.set(mx, my, mz - 1);
1907
  moonGlow.visible = true;
1908
+ updateMoonPhase(moonPhaseDay);
1909
  } else {
1910
  moonMesh.visible = false;
1911
+ moonShadow.visible = false;
1912
  moonGlow.visible = false;
1913
  }
1914
  starPoints.visible = hour >= 18 || hour <= 5;
 
2348
  const bldg = buildingMeshes.get(buildingId);
2349
  if (!bldg) return;
2350
  bldg.traverse(child => {
2351
+ if (!child.isMesh) return;
2352
+ if (child.userData?.isFurniture || child.userData?.isDoor) return;
2353
+ child.userData._savedOpacity = child.material.opacity;
2354
+ child.userData._savedTransparent = child.material.transparent;
2355
+ child.userData._savedDepthWrite = child.material.depthWrite;
2356
+ child.material.transparent = true;
2357
+ if (child.userData?.isWindow) {
2358
+ child.material.opacity = 0.25;
2359
+ } else {
2360
+ child.material.opacity = 0.08;
2361
  child.material.depthWrite = false;
2362
  }
 
 
 
 
 
 
2363
  });
2364
  smoothZoomTo(bldg.position, 8);
2365
  }
 
2502
  html += `<div class="info-section">`;
2503
  html += `<h4>Status</h4>`;
2504
  html += `<div class="info-row"><span class="label">Location</span><span class="value">${data.location_name || data.location || '?'}</span></div>`;
2505
+ const isDead = data.state === 'deceased';
2506
+ if (isDead) {
2507
+ html += `<div class="info-row"><span class="label">State</span><span class="value" style="color:#e94560">Deceased</span></div>`;
2508
+ if (data.deathDate) html += `<div class="info-row"><span class="label">Death</span><span class="value">${data.deathDate}</span></div>`;
2509
+ if (data.deathAge) html += `<div class="info-row"><span class="label">Age at death</span><span class="value">${data.deathAge}</span></div>`;
2510
+ } else {
2511
+ html += `<div class="info-row"><span class="label">State</span><span class="value">${data.state || '?'}</span></div>`;
2512
+ html += `<div class="info-row"><span class="label">Action</span><span class="value">${data.current_action || data.action || data.state || '?'}</span></div>`;
2513
+ if (data.mood !== undefined) {
2514
+ html += `<div class="info-row"><span class="label">Mood</span><span class="value">${typeof data.mood === 'number' ? (data.mood * 100).toFixed(0) + '%' : data.mood}</span></div>`;
2515
+ }
2516
  }
2517
+ if (data.age !== undefined && !isDead) {
2518
  html += `<div class="info-row"><span class="label">Age</span><span class="value">${data.age}</span></div>`;
2519
  }
2520
  if (data.gender) {
2521
  html += `<div class="info-row"><span class="label">Gender</span><span class="value">${data.gender}</span></div>`;
2522
  }
2523
+ if (data.occupation && !isDead) {
2524
  html += `<div class="info-row"><span class="label">Occupation</span><span class="value">${data.occupation}</span></div>`;
2525
  }
2526
+ if (data.lifePhase && !isDead) {
2527
  html += `<div class="info-row"><span class="label">Life Phase</span><span class="value">${data.lifePhase}</span></div>`;
2528
  }
2529
  html += `</div>`;
 
2539
  html += `</div>`;
2540
  }
2541
 
2542
+ // Needs (hide for deceased)
2543
  const needs = data.needs || {};
2544
+ if (!isDead && Object.keys(needs).length > 0) {
2545
  html += `<div class="info-section"><h4>Needs</h4>`;
2546
  for (const [need, val] of Object.entries(needs)) {
2547
  const pct = typeof val === 'number' ? val * 100 : parseFloat(val) * 100;
 
2668
  const clock = new THREE.Clock();
2669
  let frameCount = 0;
2670
 
2671
+ renderer.setAnimationLoop(animate);
2672
  function animate() {
 
2673
  const dt = clock.getDelta();
2674
  frameCount++;
2675
 
2676
+ if (fpMode) {
2677
+ updateFPMovement();
2678
+ updateNPCFacing();
2679
+ updatePlayerPosition();
2680
+ } else {
2681
+ controls.update();
2682
+ updateCameraAnimation();
2683
+ }
2684
 
2685
  // Smooth agent movement
2686
  for (const [agentId, mesh] of agentMeshes) {
 
2689
  const dx = target.x - mesh.position.x;
2690
  const dz = target.z - mesh.position.z;
2691
  const dist0 = Math.sqrt(dx * dx + dz * dz);
2692
+ const speed = 0.12;
2693
 
2694
  if (dist0 > speed) {
2695
  let mx = (dx / dist0) * speed;
 
2752
  }
2753
  }
2754
 
2755
+ mesh.visible = true;
 
 
 
 
2756
 
2757
  // Sleeping, sitting, walking, or idle animation
2758
  const agentState = mesh.userData.data?.state || '';
 
2766
  const isSitting = !moving && atLocation && SITTING_LOCS.has(agentLoc);
2767
 
2768
  if (isSleeping) {
2769
+ mesh.rotation.x = 0;
2770
+ mesh.rotation.z = Math.PI / 2;
2771
+ mesh.position.y = 0.30;
2772
+ if (mesh.userData.leftLeg) mesh.userData.leftLeg.rotation.x = 0.1;
2773
+ if (mesh.userData.rightLeg) mesh.userData.rightLeg.rotation.x = -0.1;
2774
+ if (mesh.userData.leftArm) mesh.userData.leftArm.rotation.x = 0.3;
2775
+ if (mesh.userData.rightArm) mesh.userData.rightArm.rotation.x = 0.3;
2776
  } else if (isSitting) {
2777
  mesh.rotation.x = 0;
2778
  mesh.position.y = 0.0;
 
2863
  if (cloud.position.x > 70) cloud.position.x = -70;
2864
  }
2865
 
2866
+ renderer.render(scene, fpMode ? fpCamera : camera);
2867
  }
2868
 
2869
  // ============================================================
 
2880
  renderer.setSize(w, h);
2881
  });
2882
 
2883
+ // ============================================================
2884
+ // FIRST-PERSON / VR MODE
2885
+ // ============================================================
2886
+ let fpMode = false;
2887
+ const fpCamera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 500);
2888
+ fpCamera.position.set(0, 1.8, 10);
2889
+ const fpControls = new PointerLockControls(fpCamera, renderer.domElement);
2890
+ const fpMoveState = { forward: false, backward: false, left: false, right: false };
2891
+ const fpVelocity = new THREE.Vector3();
2892
+ const fpDirection = new THREE.Vector3();
2893
+ const FP_SPEED = 12;
2894
+ const FP_HEIGHT = 1.8;
2895
+ let fpClock = new THREE.Clock();
2896
+
2897
+ fpControls.addEventListener('lock', () => {
2898
+ document.getElementById('fp-crosshair').style.display = 'block';
2899
+ document.getElementById('fp-hint').style.display = 'block';
2900
+ setTimeout(() => { document.getElementById('fp-hint').style.display = 'none'; }, 5000);
2901
+ });
2902
+ fpControls.addEventListener('unlock', () => {
2903
+ document.getElementById('fp-crosshair').style.display = 'none';
2904
+ if (fpMode) {
2905
+ setTimeout(() => { if (fpMode) fpControls.lock(); }, 200);
2906
+ }
2907
+ });
2908
+
2909
+ function enterFPMode() {
2910
+ fpMode = true;
2911
+ fpCamera.position.set(0, FP_HEIGHT, 10);
2912
+ fpCamera.aspect = window.innerWidth / window.innerHeight;
2913
+ fpCamera.updateProjectionMatrix();
2914
+ controls.enabled = false;
2915
+ fpControls.lock();
2916
+ fpClock.start();
2917
+ document.getElementById('btn-fp').classList.add('active');
2918
+ document.getElementById('btn-fp').title = 'Exit first-person';
2919
+ }
2920
+
2921
+ function exitFPMode() {
2922
+ fpMode = false;
2923
+ fpControls.unlock();
2924
+ controls.enabled = true;
2925
+ fpClock.stop();
2926
+ document.getElementById('fp-crosshair').style.display = 'none';
2927
+ document.getElementById('fp-hint').style.display = 'none';
2928
+ document.getElementById('btn-fp').classList.remove('active');
2929
+ document.getElementById('btn-fp').title = 'First-person view';
2930
+ }
2931
+
2932
+ window._toggleFP = () => {
2933
+ if (fpMode) exitFPMode(); else enterFPMode();
2934
+ };
2935
+
2936
+ function updateFPMovement() {
2937
+ if (!fpMode) return;
2938
+ const delta = Math.min(fpClock.getDelta(), 0.1);
2939
+ fpDirection.z = Number(fpMoveState.forward) - Number(fpMoveState.backward);
2940
+ fpDirection.x = Number(fpMoveState.right) - Number(fpMoveState.left);
2941
+ fpDirection.normalize();
2942
+
2943
+ fpVelocity.x = fpDirection.x * FP_SPEED * delta;
2944
+ fpVelocity.z = fpDirection.z * FP_SPEED * delta;
2945
+
2946
+ fpControls.moveRight(fpVelocity.x);
2947
+ fpControls.moveForward(fpVelocity.z);
2948
+ fpCamera.position.y = FP_HEIGHT;
2949
+
2950
+ // Keep within world bounds
2951
+ fpCamera.position.x = Math.max(-HALF, Math.min(HALF, fpCamera.position.x));
2952
+ fpCamera.position.z = Math.max(-HALF, Math.min(HALF, fpCamera.position.z));
2953
+ }
2954
+
2955
+ // FP interaction: E key to interact with nearest agent/building
2956
+ function fpInteract() {
2957
+ if (!fpMode) return;
2958
+ const ray = new THREE.Raycaster();
2959
+ ray.setFromCamera(new THREE.Vector2(0, 0), fpCamera);
2960
+ ray.far = 10;
2961
+
2962
+ const agentObjs = [];
2963
+ for (const [, m] of agentMeshes) agentObjs.push(m);
2964
+ const hits = ray.intersectObjects(agentObjs, true);
2965
+ if (hits.length > 0) {
2966
+ let p = hits[0].object;
2967
+ while (p.parent && !p.userData?.id) p = p.parent;
2968
+ if (p.userData?.id && p.userData.type === 'agent') {
2969
+ selectAgent(p.userData.id);
2970
+ return;
2971
+ }
2972
+ }
2973
+
2974
+ const bldgObjs = [];
2975
+ for (const [, m] of buildingMeshes) bldgObjs.push(m);
2976
+ const bHits = ray.intersectObjects(bldgObjs, true);
2977
+ if (bHits.length > 0) {
2978
+ let p = bHits[0].object;
2979
+ while (p.parent && !p.userData?.id) p = p.parent;
2980
+ if (p.userData?.id) {
2981
+ selectBuilding(p.userData.id);
2982
+ enterInterior(p.userData.id);
2983
+ }
2984
+ }
2985
+ }
2986
+
2987
+ // WebXR support
2988
+ if (navigator.xr) {
2989
+ navigator.xr.isSessionSupported('immersive-vr').then(supported => {
2990
+ if (supported) {
2991
+ const vrBtn = document.getElementById('btn-vr');
2992
+ vrBtn.style.display = '';
2993
+ vrBtn.onclick = () => {
2994
+ navigator.xr.requestSession('immersive-vr', { optionalFeatures: ['local-floor', 'hand-tracking'] }).then(session => {
2995
+ renderer.xr.enabled = true;
2996
+ renderer.xr.setSession(session);
2997
+ fpCamera.position.set(0, FP_HEIGHT, 10);
2998
+ session.addEventListener('end', () => { renderer.xr.enabled = false; });
2999
+ });
3000
+ };
3001
+ }
3002
+ });
3003
+ }
3004
+
3005
+ // NPC facing: make nearby agents turn toward the player in FP mode
3006
+ function updateNPCFacing() {
3007
+ if (!fpMode) return;
3008
+ const pp = fpCamera.position;
3009
+ for (const [, mesh] of agentMeshes) {
3010
+ const dx = pp.x - mesh.position.x;
3011
+ const dz = pp.z - mesh.position.z;
3012
+ const dist = Math.sqrt(dx * dx + dz * dz);
3013
+ if (dist < 8 && dist > 0.3) {
3014
+ mesh.rotation.y = Math.atan2(dx, dz);
3015
+ }
3016
+ }
3017
+ }
3018
+
3019
+ // Resize handler for FP camera
3020
+ window.addEventListener('resize', () => {
3021
+ fpCamera.aspect = window.innerWidth / window.innerHeight;
3022
+ fpCamera.updateProjectionMatrix();
3023
+ });
3024
+
3025
+ // ============================================================
3026
+ // PLAYER CHARACTER
3027
+ // ============================================================
3028
+ let playerData = null;
3029
+ let playerMesh = null;
3030
+
3031
+ window._showJoin = () => {
3032
+ document.getElementById('player-login').style.display = 'block';
3033
+ };
3034
+
3035
+ window._joinCity = () => {
3036
+ const name = document.getElementById('player-name').value.trim() || 'Player';
3037
+ const gender = document.getElementById('player-gender').value;
3038
+ const age = parseInt(document.getElementById('player-age').value) || 25;
3039
+ document.getElementById('player-login').style.display = 'none';
3040
+
3041
+ playerData = { name, gender, age, location: 'town_square', state: 'exploring', needs: { hunger: 0.8, energy: 0.9, social: 0.6, fun: 0.7 } };
3042
+
3043
+ const playerAgentData = { ...playerData, occupation: 'Explorer', lifePhase: 'playing' };
3044
+ playerMesh = createAgentMesh('player', playerAgentData);
3045
+ const sq = LOCATION_POSITIONS['town_square'];
3046
+ const pos = toWorld(sq.x, sq.y);
3047
+ playerMesh.position.set(pos.x, 0, pos.z);
3048
+
3049
+ document.getElementById('player-hud').style.display = 'block';
3050
+ document.getElementById('phud-name').textContent = name;
3051
+ updatePlayerHUD();
3052
+
3053
+ document.getElementById('btn-join').textContent = 'PLAYING';
3054
+ document.getElementById('btn-join').classList.add('active');
3055
+
3056
+ enterFPMode();
3057
+ fpCamera.position.set(pos.x, FP_HEIGHT, pos.z + 2);
3058
+ };
3059
+
3060
+ function updatePlayerHUD() {
3061
+ if (!playerData) return;
3062
+ const n = playerData.needs;
3063
+ let html = '';
3064
+ for (const [k, v] of Object.entries(n)) {
3065
+ const pct = (v * 100).toFixed(0);
3066
+ const color = v > 0.6 ? '#4ecca3' : v > 0.3 ? '#f0c040' : '#e94560';
3067
+ html += `<div style="display:flex;align-items:center;gap:4px;margin:2px 0">`;
3068
+ html += `<span style="width:50px;color:#aaa">${k}</span>`;
3069
+ html += `<div style="flex:1;height:6px;background:rgba(255,255,255,0.1);border-radius:3px"><div style="width:${pct}%;height:100%;background:${color};border-radius:3px"></div></div>`;
3070
+ html += `<span style="width:28px;text-align:right;color:#888">${pct}%</span></div>`;
3071
+ }
3072
+ document.getElementById('phud-stats').innerHTML = html;
3073
+ }
3074
+
3075
+ function updatePlayerPosition() {
3076
+ if (!playerData || !playerMesh || !fpMode) return;
3077
+ playerMesh.position.set(fpCamera.position.x, 0, fpCamera.position.z);
3078
+ playerMesh.rotation.y = fpCamera.rotation.y;
3079
+ playerMesh.visible = false;
3080
+ }
3081
+
3082
  // ============================================================
3083
  // KEYBOARD
3084
  // ============================================================
3085
  document.addEventListener('keydown', (e) => {
3086
+ if (e.key === 'Escape') { if (fpMode) { exitFPMode(); return; } closeInfoPanel(); }
3087
+ if (fpMode) {
3088
+ if (e.key === 'w' || e.key === 'W' || e.key === 'ArrowUp') fpMoveState.forward = true;
3089
+ if (e.key === 's' || e.key === 'S' || e.key === 'ArrowDown') fpMoveState.backward = true;
3090
+ if (e.key === 'a' || e.key === 'A' || e.key === 'ArrowLeft') fpMoveState.left = true;
3091
+ if (e.key === 'd' || e.key === 'D' || e.key === 'ArrowRight') fpMoveState.right = true;
3092
+ if (e.key === 'e' || e.key === 'E') fpInteract();
3093
+ return;
3094
+ }
3095
  if (e.key === 'r' || e.key === 'R') resetCamera();
3096
  if (e.key === '+' || e.key === '=') zoomIn();
3097
  if (e.key === '-') zoomOut();
3098
  });
3099
+ document.addEventListener('keyup', (e) => {
3100
+ if (e.key === 'w' || e.key === 'W' || e.key === 'ArrowUp') fpMoveState.forward = false;
3101
+ if (e.key === 's' || e.key === 'S' || e.key === 'ArrowDown') fpMoveState.backward = false;
3102
+ if (e.key === 'a' || e.key === 'A' || e.key === 'ArrowLeft') fpMoveState.left = false;
3103
+ if (e.key === 'd' || e.key === 'D' || e.key === 'ArrowRight') fpMoveState.right = false;
3104
+ });
3105
 
3106
  // ============================================================
3107
  // FALLBACK: REST polling if WebSocket isn't available
 
3135
  }
3136
  connectWebSocket();
3137
  setInterval(pollFallback, 3000);
 
3138
  updateDetailLevel();
3139
 
3140
  let demoSpeedMultiplier = 1.0;
 
3225
 
3226
  let demoDay = 1;
3227
  let demoMinute = 630;
3228
+ let demoMonth = 4; // Start April 1st
3229
+ let demoDayOfMonth = 1;
3230
+ let demoYear = 2026;
3231
+
3232
+ function getSeason() {
3233
+ if (demoMonth >= 3 && demoMonth <= 5) return 'spring';
3234
+ if (demoMonth >= 6 && demoMonth <= 8) return 'summer';
3235
+ if (demoMonth >= 9 && demoMonth <= 11) return 'autumn';
3236
+ return 'winter';
3237
+ }
3238
+ const DAYS_IN_MONTH = [0,31,28,31,30,31,30,31,31,30,31,30,31];
3239
+ function advanceCalendar() {
3240
+ demoDayOfMonth++;
3241
+ if (demoDayOfMonth > DAYS_IN_MONTH[demoMonth]) {
3242
+ demoDayOfMonth = 1;
3243
+ demoMonth++;
3244
+ if (demoMonth > 12) { demoMonth = 1; demoYear++; ageAllAgents(); }
3245
+ }
3246
+ }
3247
+ function ageAllAgents() {
3248
+ for (const id of allIds) {
3249
+ if (!agentLife[id]?.alive) continue;
3250
+ agentLife[id].age++;
3251
+ demoAgents[id].age = agentLife[id].age;
3252
+ const a = agentLife[id].age;
3253
+ if (a === 3) { agentLife[id].lifePhase = 'kindergarten'; agentLife[id].occupation = 'Child'; }
3254
+ if (a === 6) { agentLife[id].lifePhase = 'school'; agentLife[id].occupation = 'Student'; agentMemories[id].push('Started school'); }
3255
+ if (a === 18) { agentLife[id].lifePhase = 'university'; agentLife[id].occupation = 'Student'; agentMemories[id].push('Graduated high school'); }
3256
+ if (a === 23) { agentLife[id].lifePhase = 'working'; agentLife[id].occupation = OCCUPATIONS[hash(id) % OCCUPATIONS.length]; agentMemories[id].push(`Started career as ${agentLife[id].occupation}`); }
3257
+ if (a === 65) { agentLife[id].lifePhase = 'retired'; agentMemories[id].push('Retired'); }
3258
+ demoAgents[id].occupation = agentLife[id].occupation;
3259
+ demoAgents[id].lifePhase = agentLife[id].lifePhase;
3260
+ }
3261
+ }
3262
+
3263
+ let currentDemoWeather = 'sunny';
3264
+ function pickWeather() {
3265
+ const season = getSeason();
3266
+ const r = Math.random();
3267
+ if (r < 0.75) return 'sunny';
3268
+ if (season === 'winter') {
3269
+ if (r < 0.82) return 'cloudy';
3270
+ if (r < 0.92) return 'snowy';
3271
+ if (r < 0.97) return 'foggy';
3272
+ return 'stormy';
3273
+ }
3274
+ if (r < 0.84) return 'cloudy';
3275
+ if (r < 0.94) return 'rainy';
3276
+ if (r < 0.97) return 'foggy';
3277
+ return 'stormy';
3278
+ }
3279
+
3280
  let tickCount = 0;
3281
  const deadAgents = [];
3282
 
3283
+ const MONTH_NAMES = ['','Яну','Фев','Мар','Апр','Май','Юни','Юли','Авг','Сеп','Окт','Ное','Дек'];
3284
  function demoTimeStr() {
3285
  const hh = Math.floor(demoMinute / 60);
3286
  const mm = demoMinute % 60;
3287
  const phase = hh >= 5 && hh < 7 ? 'dawn' : hh >= 7 && hh < 12 ? 'morning' : hh >= 12 && hh < 17 ? 'afternoon' : hh >= 17 && hh < 20 ? 'evening' : 'night';
3288
+ return `Day ${demoDay} (${demoDayOfMonth} ${MONTH_NAMES[demoMonth]} ${demoYear}), ${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')} (${phase})`;
3289
  }
3290
 
3291
  function getLifePlan(id) {
 
3305
  const aliveIds = allIds.filter(id => agentLife[id]?.alive);
3306
  tickCount++;
3307
 
3308
+ // Aging is now handled by calendar (ageAllAgents on Jan 1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3309
 
3310
  // Marriage: every 4 ticks, try to match single adults
3311
  if (tickCount % 4 === 0) {
 
3383
  agentLife[id].alive = false;
3384
  demoAgents[id].location = 'cemetery';
3385
  demoAgents[id].state = 'deceased';
3386
+ demoAgents[id].deathDate = `${demoDayOfMonth} ${MONTH_NAMES[demoMonth]} ${demoYear}`;
3387
+ demoAgents[id].deathAge = a;
3388
+ demoAgents[id].needs = null;
3389
  deadAgents.push({ id, name: demoAgents[id].name, age: a, day: demoDay });
3390
+ agentMemories[id].push(`Passed away at age ${a} (${demoDayOfMonth} ${MONTH_NAMES[demoMonth]} ${demoYear})`);
3391
  if (agentLife[id].partner) {
3392
  const p = agentLife[id].partner;
3393
  agentLife[p].partner = null;
 
3412
 
3413
  handleStateUpdate({
3414
  type: 'tick', time: demoTimeStr(),
3415
+ state: { agents: demoAgents, locations: {}, weather: currentDemoWeather }
3416
  });
3417
  document.getElementById('sim-agents').textContent = `${Object.keys(demoAgents).length} agents (demo)`;
3418
 
 
3422
  if (wsConnected || demoPaused) return;
3423
 
3424
  demoMinute += 20;
3425
+ if (demoMinute >= 1440) { demoMinute -= 1440; demoDay++; moonPhaseDay++; advanceCalendar(); }
3426
  const hh = Math.floor(demoMinute / 60);
3427
+ if (demoMinute % 60 === 0 && hh === 6) {
3428
+ currentDemoWeather = pickWeather();
3429
  }
3430
 
3431
+ const w = currentDemoWeather;
3432
  const badW = w === 'rainy' || w === 'stormy' || w === 'snowy';
3433
  const isNightTime = hh >= 22 || hh < 6;
3434
  const isLateEvening = hh >= 20 && hh < 22;