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>
- 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">▲</button>
|
| 154 |
<button class="ctrl-btn" onclick="zoomIn()" title="Zoom in">+</button>
|
| 155 |
<button class="ctrl-btn" onclick="zoomOut()" title="Zoom out">−</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
|
| 664 |
-
const bed = new THREE.Mesh(
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
);
|
| 668 |
-
|
| 669 |
-
group.add(
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 (
|
| 2286 |
-
|
| 2287 |
-
|
| 2288 |
-
|
| 2289 |
-
|
| 2290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2442 |
-
|
| 2443 |
-
|
| 2444 |
-
html += `<div class="info-row"><span class="label">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2606 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 2616 |
|
| 2617 |
if (dist0 > speed) {
|
| 2618 |
let mx = (dx / dist0) * speed;
|
|
@@ -2675,11 +2752,7 @@ function animate() {
|
|
| 2675 |
}
|
| 2676 |
}
|
| 2677 |
|
| 2678 |
-
|
| 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 =
|
| 2697 |
-
mesh.rotation.z =
|
| 2698 |
-
mesh.position.y = 0.
|
| 2699 |
-
if (mesh.userData.leftLeg) mesh.userData.leftLeg.rotation.x = 0.
|
| 2700 |
-
if (mesh.userData.rightLeg) mesh.userData.rightLeg.rotation.x = -0.
|
| 2701 |
-
if (mesh.userData.leftArm) mesh.userData.leftArm.rotation.x = 0.
|
| 2702 |
-
if (mesh.userData.rightArm) mesh.userData.rightArm.rotation.x = 0.
|
| 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 |
-
|
| 2944 |
-
let
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 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} (
|
| 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:
|
| 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
|
| 3103 |
-
|
| 3104 |
}
|
| 3105 |
|
| 3106 |
-
const w =
|
| 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">▲</button>
|
| 183 |
<button class="ctrl-btn" onclick="zoomIn()" title="Zoom in">+</button>
|
| 184 |
<button class="ctrl-btn" onclick="zoomOut()" title="Zoom out">−</button>
|
| 185 |
+
<button class="ctrl-btn" id="btn-fp" onclick="window._toggleFP()" title="First-person view">👁</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 · Mouse — look · ESC — exit · 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;
|