Initial commit of the complete City10 virtual city simulation platform. Backend (Laravel 13): - REST API with Sanctum tokens and session-based web authentication - Role-based access control (admin/user) with middleware protection - AI agent CRUD with OpenRouter.ai integration restricted to free models - Store management with 3D coordinates and AI shopkeeper assignment - Versioned legal documents (Terms, Privacy, Cookies) with LGPD audit trail - Math CAPTCHA on public forms and multi-language support (pt_BR, en, es) Frontend: - Responsive Blade templates with Tailwind CSS v4 - Three.js 3D city viewer with SSE chat streaming to AI agents - Admin panel for store and user administration Infrastructure: - MariaDB/MySQL database with comprehensive migrations - Database seeders for demo content and test accounts - PHPUnit test suite and Laravel Pint code styling - Vite build configuration with hot reload
995 lines
42 KiB
PHP
995 lines
42 KiB
PHP
@extends('layouts.app')
|
||
|
||
@section('title', __('city.title'))
|
||
|
||
@section('content')
|
||
<div style="max-width: 1280px; margin: 0 auto;">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; margin-bottom:1rem;">
|
||
<h1 style="margin:0; color:#fff;">🏙️ {{ __('city.title') }}</h1>
|
||
<div style="display:flex; gap:.6rem; align-items:center; flex-wrap:wrap;">
|
||
<span id="stats" style="color:var(--muted); font-size:.9rem;"></span>
|
||
<button type="button" id="reload" class="btn">{{ __('city.refresh') }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="scene"
|
||
style="position:relative; width:100%; height:min(78vh, 720px); border-radius:1rem; overflow:hidden;
|
||
background:#0b1220; border:1px solid var(--border); box-shadow:0 20px 50px rgba(0,0,0,.45);">
|
||
<div id="loading" style="position:absolute; inset:0; display:grid; place-items:center; color:var(--muted);">
|
||
{{ __('city.loading') }}
|
||
</div>
|
||
<div id="error" style="position:absolute; inset:0; display:none; place-items:center; color:#fecaca;">
|
||
{{ __('city.error') }}
|
||
</div>
|
||
|
||
{{-- Click-to-start overlay (pointer lock gate) --}}
|
||
<div id="start-gate"
|
||
style="position:absolute; inset:0; display:grid; place-items:center; background:rgba(11,18,32,.75);
|
||
cursor:pointer; z-index:10; text-align:center;">
|
||
<div>
|
||
<div style="font-size:3rem; margin-bottom:.5rem;">🎮</div>
|
||
<p style="color:#fff; font-size:1.1rem; margin:0 0 .3rem;">{{ __('city.hint_fps') }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- HUD bottom-left: POSIÇÃO panel (like example images) --}}
|
||
<div id="hud-pos"
|
||
style="position:absolute; left:.6rem; bottom:.6rem; z-index:5; display:none;
|
||
background:rgba(10,15,28,.88); border:1px solid rgba(148,163,184,.25); border-radius:.4rem;
|
||
padding:.5rem .7rem; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.72rem;
|
||
color:#94a3b8; line-height:1.55; min-width:170px; pointer-events:none;">
|
||
<div style="color:#e2e8f0; font-weight:700; letter-spacing:.06em; font-size:.65rem; margin-bottom:.2rem;">{{ __('city.position') }}</div>
|
||
<div id="pos-xyz">x:0.0 y:1.7 z:0.0</div>
|
||
<div id="pos-ground">—</div>
|
||
<div id="pos-tris">—</div>
|
||
</div>
|
||
|
||
{{-- HUD bottom-right: selected / hint --}}
|
||
<div id="hud-hint"
|
||
style="position:absolute; right:.6rem; bottom:.6rem; z-index:5; display:flex; gap:.5rem;
|
||
pointer-events:none; flex-wrap:wrap; justify-content:flex-end;">
|
||
<span id="selected" style="background:rgba(15,23,42,.85); border:1px solid var(--border); border-radius:.5rem;
|
||
padding:.35rem .6rem; font-size:.8rem; color:#7dd3fc; display:none;"></span>
|
||
<span id="hint" style="background:rgba(15,23,42,.85); border:1px solid var(--border); border-radius:.5rem;
|
||
padding:.35rem .6rem; font-size:.75rem; color:var(--muted);">{{ __('city.hint') }}</span>
|
||
</div>
|
||
|
||
{{-- Chat panel (bottom-right, like example images) --}}
|
||
<div id="chat-panel"
|
||
style="position:absolute; right:.6rem; bottom:3.2rem; z-index:15; width:280px; display:none;
|
||
background:rgba(15,23,42,.96); border:1px solid var(--border); border-radius:.6rem;
|
||
overflow:hidden; box-shadow:0 12px 30px rgba(0,0,0,.5); flex-direction:column;">
|
||
<div id="chat-header"
|
||
style="display:flex; align-items:center; justify-content:space-between; gap:.5rem;
|
||
padding:.55rem .7rem; background:rgba(30,41,59,.9); border-bottom:1px solid var(--border);">
|
||
<div style="display:flex; align-items:center; gap:.5rem; min-width:0;">
|
||
<span id="chat-dot" style="width:8px; height:8px; border-radius:50%; background:#22c55e; flex-shrink:0;"></span>
|
||
<div style="min-width:0;">
|
||
<div id="chat-name" style="font-weight:700; font-size:.85rem; color:#fff; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"></div>
|
||
<div id="chat-status" style="font-size:.7rem; color:var(--muted);"></div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex; gap:.3rem;">
|
||
<span id="chat-time" style="font-size:.65rem; color:var(--muted); align-self:center;"></span>
|
||
<button type="button" id="chat-close"
|
||
style="background:transparent; border:none; color:var(--muted); cursor:pointer; font-size:1rem; padding:0 .2rem;"
|
||
aria-label="Close">×</button>
|
||
</div>
|
||
</div>
|
||
<div id="chat-messages"
|
||
style="padding:.6rem .7rem; overflow-y:auto; max-height:260px; display:flex; flex-direction:column;
|
||
gap:.5rem; font-size:.78rem; line-height:1.5;"></div>
|
||
<form id="chat-form"
|
||
style="display:flex; gap:.4rem; padding:.5rem .6rem; border-top:1px solid var(--border); background:rgba(11,18,32,.7);">
|
||
<input id="chat-input" type="text" maxlength="500" required
|
||
placeholder="{{ __('city.chat_placeholder') }}"
|
||
style="flex:1; min-width:0; padding:.45rem .6rem; border-radius:.4rem; border:1px solid var(--border);
|
||
background:#0b1220; color:var(--text); font-size:.8rem;">
|
||
<button type="submit" class="btn btn-primary" style="padding:.4rem .7rem; font-size:.75rem;">
|
||
{{ __('city.chat_send') }}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
@endsection
|
||
|
||
@push('styles')
|
||
<style>
|
||
#scene canvas { display: block; touch-action: none; }
|
||
#chat-messages::-webkit-scrollbar { width: 6px; }
|
||
#chat-messages::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
|
||
.chat-bubble {
|
||
padding:.4rem .6rem; border-radius:.5rem; max-width:92%; word-break:break-word;
|
||
}
|
||
.chat-bubble.user {
|
||
background:rgba(14,165,233,.22); border:1px solid rgba(14,165,233,.4); color:#bae6fd; align-self:flex-end;
|
||
}
|
||
.chat-bubble.npc {
|
||
background:rgba(51,65,85,.55); border:1px solid rgba(148,163,184,.2); color:#e2e8f0; align-self:flex-start;
|
||
}
|
||
.chat-bubble.meta {
|
||
background:transparent; border:none; color:var(--muted); font-size:.7rem; align-self:center;
|
||
}
|
||
@media (max-width: 640px) {
|
||
#scene { height: 62vh !important; }
|
||
#hint { display: none; }
|
||
#chat-panel { width: calc(100% - 1.2rem); right: .6rem; left: .6rem; }
|
||
}
|
||
</style>
|
||
@endpush
|
||
|
||
@push('scripts')
|
||
<script type="module">
|
||
import * as THREE from '/vendor/three/three.module.min.js';
|
||
|
||
const host = document.getElementById('scene');
|
||
const loadingEl = document.getElementById('loading');
|
||
const errorEl = document.getElementById('error');
|
||
const startGate = document.getElementById('start-gate');
|
||
const statsEl = document.getElementById('stats');
|
||
const selectedEl = document.getElementById('selected');
|
||
const reloadBtn = document.getElementById('reload');
|
||
const posPanel = document.getElementById('hud-pos');
|
||
const posXYZ = document.getElementById('pos-xyz');
|
||
const posGround = document.getElementById('pos-ground');
|
||
const posTris = document.getElementById('pos-tris');
|
||
const dataUrl = @json(route('city.data'));
|
||
const csrf = @json(csrf_token());
|
||
const i18n = {
|
||
stores: @json(__('city.stores_count')),
|
||
agents: @json(__('city.agents_count')),
|
||
open: @json(__('city.open')),
|
||
closed: @json(__('city.closed')),
|
||
selected: @json(__('city.selected')),
|
||
position: @json(__('city.position')),
|
||
onGround: @json(__('city.on_ground')),
|
||
tris: @json(__('city.triangles')),
|
||
chatThinking: @json(__('city.chat_thinking')),
|
||
chatResponding: @json(__('city.chat_responding')),
|
||
chatWith: @json(__('city.chat_with')),
|
||
chatSend: @json(__('city.chat_send')),
|
||
chatError: @json(__('city.chat_error')),
|
||
};
|
||
|
||
// ------------------------------------------------------------------
|
||
// Scene setup — daytime sky, fog, lighting like the example images
|
||
// ------------------------------------------------------------------
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x87ceeb); // sky blue
|
||
scene.fog = new THREE.Fog(0x87ceeb, 120, 600);
|
||
|
||
const camera = new THREE.PerspectiveCamera(72, 1, 0.1, 2000);
|
||
camera.position.set(0, 1.7, 0); // eye height 1.7m
|
||
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
renderer.shadowMap.enabled = true;
|
||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||
renderer.toneMappingExposure = 1.05;
|
||
host.appendChild(renderer.domElement);
|
||
|
||
// Lights
|
||
scene.add(new THREE.HemisphereLight(0xbfdfff, 0x556b2f, 0.55));
|
||
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
|
||
const sun = new THREE.DirectionalLight(0xfff2d9, 1.15);
|
||
sun.position.set(80, 140, 60);
|
||
sun.castShadow = true;
|
||
sun.shadow.mapSize.set(2048, 2048);
|
||
sun.shadow.camera.left = -200;
|
||
sun.shadow.camera.right = 200;
|
||
sun.shadow.camera.top = 200;
|
||
sun.shadow.camera.bottom = -200;
|
||
sun.shadow.camera.far = 500;
|
||
scene.add(sun);
|
||
|
||
// ------------------------------------------------------------------
|
||
// Texture helpers (canvas-generated — no external assets required)
|
||
// ------------------------------------------------------------------
|
||
function canvasTexture(size, draw, repeatX = 1, repeatY = 1) {
|
||
const c = document.createElement('canvas');
|
||
c.width = size; c.height = size;
|
||
const ctx = c.getContext('2d');
|
||
draw(ctx, size);
|
||
const tex = new THREE.CanvasTexture(c);
|
||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||
tex.repeat.set(repeatX, repeatY);
|
||
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
||
return tex;
|
||
}
|
||
|
||
// Asphalt with subtle noise + double white center lines
|
||
const asphaltTex = canvasTexture(512, (ctx, s) => {
|
||
ctx.fillStyle = '#1a1a1e';
|
||
ctx.fillRect(0, 0, s, s);
|
||
// noise
|
||
for (let i = 0; i < 9000; i++) {
|
||
const v = 20 + Math.random() * 30;
|
||
ctx.fillStyle = `rgb(${v},${v},${v + 4})`;
|
||
ctx.fillRect(Math.random() * s, Math.random() * s, 1, 1);
|
||
}
|
||
// double white lines down the center
|
||
ctx.fillStyle = '#e8e8e8';
|
||
ctx.fillRect(s * 0.44, 0, 4, s);
|
||
ctx.fillRect(s * 0.54, 0, 4, s);
|
||
// dashed side lines
|
||
ctx.fillStyle = '#d0d0d0';
|
||
for (let y = 0; y < s; y += 48) {
|
||
ctx.fillRect(s * 0.08, y, 4, 26);
|
||
ctx.fillRect(s * 0.90, y, 4, 26);
|
||
}
|
||
});
|
||
|
||
// Sidewalk — light grey tiles
|
||
const sidewalkTex = canvasTexture(256, (ctx, s) => {
|
||
ctx.fillStyle = '#b8bcc4';
|
||
ctx.fillRect(0, 0, s, s);
|
||
const tile = 64;
|
||
ctx.strokeStyle = '#9aa0a8';
|
||
ctx.lineWidth = 2;
|
||
for (let i = 0; i <= s; i += tile) {
|
||
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, s); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(s, i); ctx.stroke();
|
||
}
|
||
// subtle stains
|
||
for (let i = 0; i < 40; i++) {
|
||
ctx.fillStyle = `rgba(120,125,130,${Math.random() * 0.15})`;
|
||
ctx.beginPath();
|
||
ctx.arc(Math.random() * s, Math.random() * s, Math.random() * 20, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
}, 4, 1);
|
||
|
||
// Building facade — mustard/yellow with dark window grid
|
||
function facadeTexture(baseColor = '#c9a84c', floorCount = 6, windowsPerFloor = 4) {
|
||
const w = 512, h = 512;
|
||
const c = document.createElement('canvas');
|
||
c.width = w; c.height = h;
|
||
const ctx = c.getContext('2d');
|
||
ctx.fillStyle = baseColor;
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
const floorH = h / floorCount;
|
||
const winW = w / (windowsPerFloor * 2 + 1);
|
||
const winH = floorH * 0.5;
|
||
|
||
for (let f = 0; f < floorCount; f++) {
|
||
// dark horizontal band between floors
|
||
ctx.fillStyle = 'rgba(30,30,30,0.85)';
|
||
ctx.fillRect(0, f * floorH + floorH - 4, w, 4);
|
||
|
||
for (let wi = 0; wi < windowsPerFloor; wi++) {
|
||
const x = (wi * 2 + 1) * winW * 0.5;
|
||
const wx = wi * (w / windowsPerFloor) + w / windowsPerFloor * 0.25;
|
||
const wy = f * floorH + floorH * 0.18;
|
||
const ww = (w / windowsPerFloor) * 0.5;
|
||
const wh = winH;
|
||
|
||
// window frame (dark)
|
||
ctx.fillStyle = '#1a1a1a';
|
||
ctx.fillRect(wx - 3, wy - 3, ww + 6, wh + 6);
|
||
|
||
// glass
|
||
ctx.fillStyle = Math.random() > 0.5 ? '#7fb3d5' : '#5a8ab0';
|
||
ctx.fillRect(wx, wy, ww, wh);
|
||
|
||
// reflection
|
||
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
||
ctx.fillRect(wx, wy, ww * 0.4, wh * 0.4);
|
||
}
|
||
}
|
||
|
||
// vertical pilasters
|
||
ctx.fillStyle = 'rgba(40,40,40,0.7)';
|
||
ctx.fillRect(0, 0, 6, h);
|
||
ctx.fillRect(w - 6, 0, 6, h);
|
||
|
||
const tex = new THREE.CanvasTexture(c);
|
||
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
||
return tex;
|
||
}
|
||
|
||
// Door texture for ground floor
|
||
function doorTexture() {
|
||
const c = document.createElement('canvas');
|
||
c.width = 128; c.height = 256;
|
||
const ctx = c.getContext('2d');
|
||
ctx.fillStyle = '#1a1a1a';
|
||
ctx.fillRect(0, 0, 128, 256);
|
||
ctx.fillStyle = '#3a2f1a';
|
||
ctx.fillRect(8, 8, 112, 240);
|
||
ctx.fillStyle = '#c9a84c';
|
||
ctx.beginPath(); ctx.arc(96, 130, 6, 0, Math.PI * 2); ctx.fill();
|
||
const tex = new THREE.CanvasTexture(c);
|
||
return tex;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// City layout from server payload
|
||
// ------------------------------------------------------------------
|
||
let cityData = null;
|
||
const colliders = []; // AABB boxes {minX,maxX,minY,maxY,minZ,maxZ}
|
||
const worldGroup = new THREE.Group();
|
||
scene.add(worldGroup);
|
||
const storeMeshes = [];
|
||
const agentMeshes = [];
|
||
|
||
function clearGroup(g) {
|
||
while (g.children.length) {
|
||
const c = g.children.pop();
|
||
c.traverse?.(o => { o.geometry?.dispose?.(); o.material?.map?.dispose?.(); o.material?.dispose?.(); });
|
||
}
|
||
}
|
||
|
||
function addCollider(cx, cy, cz, w, h, d) {
|
||
colliders.push({
|
||
minX: cx - w / 2, maxX: cx + w / 2,
|
||
minY: cy, maxY: cy + h,
|
||
minZ: cz - d / 2, maxZ: cz + d / 2,
|
||
});
|
||
}
|
||
|
||
function buildCity(data) {
|
||
clearGroup(worldGroup);
|
||
colliders.length = 0;
|
||
storeMeshes.length = 0;
|
||
agentMeshes.length = 0;
|
||
|
||
const layout = data.layout || { roads: [], blocks: [] };
|
||
const stores = data.stores || [];
|
||
const agents = data.agents || [];
|
||
|
||
// ---- Ground ----
|
||
const ground = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(2000, 2000),
|
||
new THREE.MeshStandardMaterial({ color: 0x555f4a, roughness: 1 })
|
||
);
|
||
ground.rotation.x = -Math.PI / 2;
|
||
ground.position.y = -0.05;
|
||
ground.receiveShadow = true;
|
||
worldGroup.add(ground);
|
||
|
||
// ---- Roads (asphalt strips with center lines) ----
|
||
const roadMat = new THREE.MeshStandardMaterial({ map: asphaltTex, roughness: 0.9, metalness: 0.05 });
|
||
const walkMat = new THREE.MeshStandardMaterial({ map: sidewalkTex.clone(), roughness: 0.95 });
|
||
walkMat.map.repeat.set(4, 1);
|
||
|
||
layout.roads.forEach(r => {
|
||
const [x1,, z1] = r.from;
|
||
const [x2,, z2] = r.to;
|
||
const w = r.width;
|
||
const len = Math.hypot(x2 - x1, z2 - z1);
|
||
const mx = (x1 + x2) / 2, mz = (z1 + z2) / 2;
|
||
|
||
// Asphalt
|
||
const geo = r.axis === 'z'
|
||
? new THREE.PlaneGeometry(w, len)
|
||
: new THREE.PlaneGeometry(len, w);
|
||
const mesh = new THREE.Mesh(geo, roadMat);
|
||
mesh.rotation.x = -Math.PI / 2;
|
||
mesh.position.set(mx, 0.01, mz);
|
||
mesh.receiveShadow = true;
|
||
worldGroup.add(mesh);
|
||
|
||
// Sidewalks on both sides (2m each)
|
||
const swWidth = 2;
|
||
const offset = w / 2 + swWidth / 2;
|
||
[-1, 1].forEach(side => {
|
||
const sg = r.axis === 'z'
|
||
? new THREE.PlaneGeometry(swWidth, len)
|
||
: new THREE.PlaneGeometry(len, swWidth);
|
||
const sm = new THREE.Mesh(sg, walkMat);
|
||
sm.rotation.x = -Math.PI / 2;
|
||
if (r.axis === 'z') {
|
||
sm.position.set(mx + side * offset, 0.12, mz);
|
||
} else {
|
||
sm.position.set(mx, 0.12, mz + side * offset);
|
||
}
|
||
sm.receiveShadow = true;
|
||
worldGroup.add(sm);
|
||
});
|
||
});
|
||
|
||
// ---- Sidewalk curb edges (thin boxes for depth) ----
|
||
layout.roads.forEach(r => {
|
||
const [x1,, z1] = r.from;
|
||
const [x2,, z2] = r.to;
|
||
const w = r.width;
|
||
const len = Math.hypot(x2 - x1, z2 - z1);
|
||
const mx = (x1 + x2) / 2, mz = (z1 + z2) / 2;
|
||
const offset = w / 2 + 0.1;
|
||
[-1, 1].forEach(side => {
|
||
const curbGeo = r.axis === 'z'
|
||
? new THREE.BoxGeometry(0.2, 0.14, len)
|
||
: new THREE.BoxGeometry(len, 0.14, 0.2);
|
||
const curb = new THREE.Mesh(curbGeo, new THREE.MeshStandardMaterial({ color: 0x888c94, roughness: 0.9 }));
|
||
if (r.axis === 'z') {
|
||
curb.position.set(mx + side * offset, 0.07, mz);
|
||
} else {
|
||
curb.position.set(mx, 0.07, mz + side * offset);
|
||
}
|
||
worldGroup.add(curb);
|
||
});
|
||
});
|
||
|
||
// ---- Street lamps every ~30 units along roads ----
|
||
const lampPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.7, metalness: 0.5 });
|
||
const lampHeadMat = new THREE.MeshStandardMaterial({ color: 0xfff8dc, emissive: 0x444422, emissiveIntensity: 0.4 });
|
||
layout.roads.forEach(r => {
|
||
const [x1,, z1] = r.from;
|
||
const [x2,, z2] = r.to;
|
||
const len = Math.hypot(x2 - x1, z2 - z1);
|
||
const w = r.width;
|
||
const spacing = 30;
|
||
const count = Math.max(1, Math.floor(len / spacing));
|
||
for (let i = 0; i <= count; i++) {
|
||
const t = i / count;
|
||
const lx = x1 + (x2 - x1) * t;
|
||
const lz = z1 + (z2 - z1) * t;
|
||
// offset to sidewalk edge
|
||
const ox = r.axis === 'z' ? w / 2 + 1.4 : 0;
|
||
const oz = r.axis === 'x' ? w / 2 + 1.4 : 0;
|
||
const side = (i % 2 === 0) ? 1 : -1;
|
||
|
||
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.1, 4.5, 6), lampPoleMat);
|
||
pole.position.set(lx + ox * side, 2.25, lz + oz * side);
|
||
pole.castShadow = true;
|
||
worldGroup.add(pole);
|
||
|
||
// arm
|
||
const arm = new THREE.Mesh(new THREE.BoxGeometry(ox ? 1.4 : 0.15, 0.1, oz ? 1.4 : 0.15), lampPoleMat);
|
||
arm.position.set(lx + ox * side * 0.7, 4.5, lz + oz * side * 0.7);
|
||
worldGroup.add(arm);
|
||
|
||
// head
|
||
const head = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.22, 0.5), lampHeadMat);
|
||
head.position.set(lx + ox * side * 1.3, 4.4, lz + oz * side * 1.3);
|
||
worldGroup.add(head);
|
||
}
|
||
});
|
||
|
||
// ---- Stores as buildings with facades ----
|
||
const palette = ['#c9a84c', '#b8763a', '#8a7a5a', '#9a9a8a', '#a08060', '#7a8a6a'];
|
||
stores.forEach((s, idx) => {
|
||
const [x, y, z] = s.position;
|
||
const [w, h, d] = s.size;
|
||
const color = palette[idx % palette.length];
|
||
|
||
const floors = Math.max(2, Math.round(h / 3));
|
||
const facadeTex = facadeTexture(color, floors, Math.max(2, Math.round(w / 3)));
|
||
|
||
const geo = new THREE.BoxGeometry(w, h, d);
|
||
const mats = [
|
||
new THREE.MeshStandardMaterial({ map: facadeTex, roughness: 0.85 }), // +x
|
||
new THREE.MeshStandardMaterial({ map: facadeTex, roughness: 0.85 }), // -x
|
||
new THREE.MeshStandardMaterial({ color: 0x3a3a3a, roughness: 0.9 }), // +y roof
|
||
new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.9 }), // -y
|
||
new THREE.MeshStandardMaterial({ map: facadeTex, roughness: 0.85 }), // +z
|
||
new THREE.MeshStandardMaterial({ map: facadeTex, roughness: 0.85 }), // -z
|
||
];
|
||
const mesh = new THREE.Mesh(geo, mats);
|
||
mesh.position.set(x, y + h / 2, z);
|
||
mesh.castShadow = true;
|
||
mesh.receiveShadow = true;
|
||
mesh.userData = { store: s };
|
||
worldGroup.add(mesh);
|
||
storeMeshes.push(mesh);
|
||
addCollider(x, y, z, w, h, d);
|
||
|
||
// Roof cap
|
||
const roof = new THREE.Mesh(
|
||
new THREE.BoxGeometry(w + 0.4, 0.35, d + 0.4),
|
||
new THREE.MeshStandardMaterial({ color: 0x1e1e1e, roughness: 0.9 })
|
||
);
|
||
roof.position.set(x, y + h + 0.17, z);
|
||
roof.castShadow = true;
|
||
worldGroup.add(roof);
|
||
|
||
// Door on ground floor facing +Z
|
||
const door = new THREE.Mesh(
|
||
new THREE.BoxGeometry(1.4, 2.4, 0.1),
|
||
new THREE.MeshStandardMaterial({ map: doorTexture(), roughness: 0.7 })
|
||
);
|
||
door.position.set(x, y + 1.2, z + d / 2 + 0.05);
|
||
worldGroup.add(door);
|
||
|
||
// Ground marker ring (kept for selection feedback)
|
||
const ring = new THREE.Mesh(
|
||
new THREE.RingGeometry(Math.max(w, d) / 2 + 0.6, Math.max(w, d) / 2 + 1.1, 32),
|
||
new THREE.MeshBasicMaterial({ color: 0x0ea5e9, side: THREE.DoubleSide, transparent: true, opacity: 0.5 })
|
||
);
|
||
ring.rotation.x = -Math.PI / 2;
|
||
ring.position.set(x, 0.15, z);
|
||
worldGroup.add(ring);
|
||
});
|
||
|
||
// ---- Procedural filler buildings in empty blocks ----
|
||
const occupied = stores.map(s => ({ x: s.position[0], z: s.position[2] }));
|
||
(layout.blocks || []).forEach((b, bi) => {
|
||
const cx = (b.min[0] + b.max[0]) / 2;
|
||
const cz = (b.min[2] + b.max[2]) / 2;
|
||
const nearStore = occupied.some(o => Math.abs(o.x - cx) < 25 && Math.abs(o.z - cz) < 25);
|
||
if (nearStore) return;
|
||
|
||
const bw = 12 + (bi * 7) % 14;
|
||
const bd = 12 + (bi * 11) % 14;
|
||
const bh = 6 + (bi * 13) % 20;
|
||
const color = palette[(bi + 2) % palette.length];
|
||
const floors = Math.max(2, Math.round(bh / 3));
|
||
const tex = facadeTexture(color, floors, Math.max(2, Math.round(bw / 3)));
|
||
|
||
const geo = new THREE.BoxGeometry(bw, bh, bd);
|
||
const mats = Array.from({ length: 6 }, () => new THREE.MeshStandardMaterial({ map: tex, roughness: 0.85 }));
|
||
mats[2] = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.9 }); // roof
|
||
mats[3] = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.9 });
|
||
|
||
const m = new THREE.Mesh(geo, mats);
|
||
m.position.set(cx, bh / 2, cz);
|
||
m.castShadow = true;
|
||
m.receiveShadow = true;
|
||
worldGroup.add(m);
|
||
addCollider(cx, 0, cz, bw, bh, bd);
|
||
|
||
// small roof
|
||
const roof = new THREE.Mesh(
|
||
new THREE.BoxGeometry(bw + 0.3, 0.3, bd + 0.3),
|
||
new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 })
|
||
);
|
||
roof.position.set(cx, bh + 0.15, cz);
|
||
worldGroup.add(roof);
|
||
});
|
||
|
||
// ---- Agents as procedural humanoid characters ----
|
||
agents.forEach(a => {
|
||
const [x, y, z] = a.position;
|
||
const char = buildHumanoid(a);
|
||
char.position.set(x, y, z);
|
||
char.rotation.y = a.rotation_y || 0;
|
||
worldGroup.add(char);
|
||
agentMeshes.push(char);
|
||
});
|
||
|
||
statsEl.textContent = `${stores.length} ${i18n.stores} · ${agents.length} ${i18n.agents}`;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Humanoid character builder (procedural, no external GLTF required)
|
||
// ------------------------------------------------------------------
|
||
function buildHumanoid(agent) {
|
||
const g = new THREE.Group();
|
||
const type = agent.type || 'pedestrian';
|
||
|
||
// Per-type palette
|
||
const configs = {
|
||
pedestrian: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x556b8a, pants: 0x2c2c2c, scale: 1 },
|
||
shopkeeper: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0xf5e6d3, pants: 0x4a3728, scale: 1 },
|
||
mayor: { skin: 0xe0ac69, hair: 0x555555, shirt: 0x1a1a2e, pants: 0x111111, scale: 1.05 },
|
||
traffic_officer: { skin: 0xd2956b, hair: 0x1a1a1a, shirt: 0x2a4a2a, pants: 0x1a1a1a, scale: 1 },
|
||
};
|
||
const cfg = configs[type] || configs.pedestrian;
|
||
const s = cfg.scale;
|
||
|
||
const skinMat = new THREE.MeshStandardMaterial({ color: cfg.skin, roughness: 0.7 });
|
||
const hairMat = new THREE.MeshStandardMaterial({ color: cfg.hair, roughness: 0.6 });
|
||
const shirtMat = new THREE.MeshStandardMaterial({ color: cfg.shirt, roughness: 0.8 });
|
||
const pantsMat = new THREE.MeshStandardMaterial({ color: cfg.pants, roughness: 0.85 });
|
||
const shoeMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.7 });
|
||
|
||
// Head
|
||
const head = new THREE.Mesh(new THREE.SphereGeometry(0.28 * s, 12, 12), skinMat);
|
||
head.position.y = 1.62 * s;
|
||
head.castShadow = true;
|
||
g.add(head);
|
||
|
||
// Hair cap
|
||
const hair = new THREE.Mesh(new THREE.SphereGeometry(0.30 * s, 12, 8, 0, Math.PI * 2, 0, Math.PI * 0.55), hairMat);
|
||
hair.position.y = 1.65 * s;
|
||
hair.castShadow = true;
|
||
g.add(hair);
|
||
|
||
// Torso
|
||
const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.24 * s, 0.55 * s, 4, 8), shirtMat);
|
||
torso.position.y = 1.12 * s;
|
||
torso.castShadow = true;
|
||
g.add(torso);
|
||
|
||
// Arms
|
||
[-1, 1].forEach(side => {
|
||
const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.07 * s, 0.5 * s, 4, 6), shirtMat);
|
||
arm.position.set(side * 0.32 * s, 1.1 * s, 0);
|
||
arm.rotation.z = side * 0.18;
|
||
arm.castShadow = true;
|
||
g.add(arm);
|
||
|
||
// Hand
|
||
const hand = new THREE.Mesh(new THREE.SphereGeometry(0.08 * s, 8, 8), skinMat);
|
||
hand.position.set(side * 0.34 * s, 0.82 * s, 0);
|
||
g.add(hand);
|
||
});
|
||
|
||
// Legs
|
||
[-1, 1].forEach(side => {
|
||
const leg = new THREE.Mesh(new THREE.CapsuleGeometry(0.1 * s, 0.6 * s, 4, 6), pantsMat);
|
||
leg.position.set(side * 0.13 * s, 0.42 * s, 0);
|
||
leg.castShadow = true;
|
||
g.add(leg);
|
||
|
||
// Shoe
|
||
const shoe = new THREE.Mesh(new THREE.BoxGeometry(0.14 * s, 0.08 * s, 0.24 * s), shoeMat);
|
||
shoe.position.set(side * 0.13 * s, 0.04 * s, 0.05 * s);
|
||
shoe.castShadow = true;
|
||
g.add(shoe);
|
||
});
|
||
|
||
// Name label (billboard)
|
||
const label = makeLabel(agent.name, agent.status);
|
||
label.position.y = 2.15 * s;
|
||
g.add(label);
|
||
|
||
g.userData = { agent, label };
|
||
return g;
|
||
}
|
||
|
||
function makeLabel(name, status) {
|
||
const c = document.createElement('canvas');
|
||
c.width = 256; c.height = 64;
|
||
const ctx = c.getContext('2d');
|
||
ctx.fillStyle = 'rgba(10,15,28,0.85)';
|
||
ctx.roundRect ? ctx.roundRect(0, 0, 256, 64, 12) : ctx.rect(0, 0, 256, 64);
|
||
ctx.fill();
|
||
ctx.fillStyle = '#e2e8f0';
|
||
ctx.font = 'bold 22px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(name, 128, 30);
|
||
const dotColor = status === 'thinking' ? '#f59e0b' : status === 'error' ? '#ef4444' : '#22c55e';
|
||
ctx.fillStyle = dotColor;
|
||
ctx.beginPath(); ctx.arc(40, 48, 6, 0, Math.PI * 2); ctx.fill();
|
||
ctx.fillStyle = '#94a3b8';
|
||
ctx.font = '14px sans-serif';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText(status || 'active', 56, 53);
|
||
|
||
const tex = new THREE.CanvasTexture(c);
|
||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false }));
|
||
sprite.scale.set(3.2, 0.8, 1);
|
||
return sprite;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// CameraController — first person WASD + pointer lock + AABB collision
|
||
// ------------------------------------------------------------------
|
||
const player = {
|
||
pos: new THREE.Vector3(0, 1.7, 25),
|
||
vel: new THREE.Vector3(),
|
||
yaw: 0, // horizontal rotation
|
||
pitch: 0, // vertical look
|
||
speed: 6,
|
||
radius: 0.35,
|
||
eyeHeight: 1.7,
|
||
onGround: true,
|
||
};
|
||
const keys = {};
|
||
let locked = false;
|
||
|
||
renderer.domElement.addEventListener('click', () => {
|
||
if (!locked) renderer.domElement.requestPointerLock();
|
||
});
|
||
startGate.addEventListener('click', () => {
|
||
renderer.domElement.requestPointerLock();
|
||
});
|
||
|
||
document.addEventListener('pointerlockchange', () => {
|
||
locked = document.pointerLockElement === renderer.domElement;
|
||
startGate.style.display = locked ? 'none' : 'grid';
|
||
posPanel.style.display = locked ? 'block' : 'none';
|
||
});
|
||
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!locked) return;
|
||
player.yaw -= e.movementX * 0.0022;
|
||
player.pitch -= e.movementY * 0.0022;
|
||
player.pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, player.pitch));
|
||
});
|
||
|
||
document.addEventListener('keydown', (e) => { keys[e.code] = true; });
|
||
document.addEventListener('keyup', (e) => { keys[e.code] = false; });
|
||
|
||
function playerAABB(px, py, pz) {
|
||
const r = player.radius;
|
||
return {
|
||
minX: px - r, maxX: px + r,
|
||
minY: py - 1.5, maxY: py + 0.3,
|
||
minZ: pz - r, maxZ: pz + r,
|
||
};
|
||
}
|
||
|
||
function overlaps(a, b) {
|
||
return a.minX < b.maxX && a.maxX > b.minX &&
|
||
a.minZ < b.maxZ && a.maxZ > b.minZ &&
|
||
a.minY < b.maxY && a.maxY > b.minY;
|
||
}
|
||
|
||
function resolveCollision(px, py, pz) {
|
||
const box = playerAABB(px, py, pz);
|
||
let hit = false;
|
||
for (const c of colliders) {
|
||
if (overlaps(box, c)) { hit = true; break; }
|
||
}
|
||
return hit;
|
||
}
|
||
|
||
function updatePlayer(dt) {
|
||
if (!locked) return;
|
||
|
||
const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
|
||
const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
|
||
const wish = new THREE.Vector3();
|
||
|
||
if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward);
|
||
if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward);
|
||
if (keys['KeyA'] || keys['ArrowLeft']) wish.sub(right);
|
||
if (keys['KeyD'] || keys['ArrowRight']) wish.add(right);
|
||
|
||
if (wish.lengthSq() > 0) wish.normalize();
|
||
|
||
const speed = keys['ShiftLeft'] ? player.speed * 1.6 : player.speed;
|
||
const step = wish.multiplyScalar(speed * dt);
|
||
|
||
// Axis-separated collision resolution
|
||
const tryX = player.pos.x + step.x;
|
||
if (!resolveCollision(tryX, player.pos.y, player.pos.z)) player.pos.x = tryX;
|
||
|
||
const tryZ = player.pos.z + step.z;
|
||
if (!resolveCollision(player.pos.x, player.pos.y, tryZ)) player.pos.z = tryZ;
|
||
|
||
// Simple gravity + ground
|
||
player.vel.y -= 18 * dt;
|
||
player.pos.y += player.vel.y * dt;
|
||
if (player.pos.y <= player.eyeHeight) {
|
||
player.pos.y = player.eyeHeight;
|
||
player.vel.y = 0;
|
||
player.onGround = true;
|
||
}
|
||
if (keys['Space'] && player.onGround) {
|
||
player.vel.y = 6;
|
||
player.onGround = false;
|
||
}
|
||
|
||
// World bounds
|
||
player.pos.x = Math.max(-400, Math.min(400, player.pos.x));
|
||
player.pos.z = Math.max(-400, Math.min(400, player.pos.z));
|
||
}
|
||
|
||
function updateCameraFromPlayer() {
|
||
camera.position.copy(player.pos);
|
||
const dir = new THREE.Vector3(
|
||
-Math.sin(player.yaw) * Math.cos(player.pitch),
|
||
Math.sin(player.pitch),
|
||
-Math.cos(player.yaw) * Math.cos(player.pitch)
|
||
);
|
||
camera.lookAt(camera.position.clone().add(dir));
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Click-to-interact: select store or open chat with NPC
|
||
// ------------------------------------------------------------------
|
||
const ray = new THREE.Raycaster();
|
||
const ndc = new THREE.Vector2();
|
||
let pointerDown = null;
|
||
|
||
renderer.domElement.addEventListener('pointerdown', (e) => {
|
||
pointerDown = { x: e.clientX, y: e.clientY, t: Date.now() };
|
||
});
|
||
|
||
renderer.domElement.addEventListener('pointerup', (e) => {
|
||
if (!pointerDown) return;
|
||
const dx = Math.abs(e.clientX - pointerDown.x);
|
||
const dy = Math.abs(e.clientY - pointerDown.y);
|
||
const dt = Date.now() - pointerDown.t;
|
||
pointerDown = null;
|
||
if (dx > 6 || dy > 6 || dt > 400) return; // drag, not click
|
||
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||
ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||
ray.setFromCamera(ndc, camera);
|
||
|
||
// Check agents first
|
||
const agentHits = ray.intersectObjects(agentMeshes, true);
|
||
if (agentHits.length) {
|
||
let obj = agentHits[0].object;
|
||
while (obj && !obj.userData?.agent) obj = obj.parent;
|
||
if (obj?.userData?.agent) {
|
||
openChat(obj.userData.agent);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Then stores
|
||
const storeHits = ray.intersectObjects(storeMeshes, false);
|
||
if (storeHits.length) {
|
||
const s = storeHits[0].object.userData.store;
|
||
selectedEl.style.display = 'inline-block';
|
||
selectedEl.textContent = `${i18n.selected}: ${s.name} · ${s.is_open ? i18n.open : i18n.closed}`;
|
||
} else {
|
||
selectedEl.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// ------------------------------------------------------------------
|
||
// ChatPanel — SSE stream + POST message
|
||
// ------------------------------------------------------------------
|
||
const chatPanel = document.getElementById('chat-panel');
|
||
const chatHeader = document.getElementById('chat-header');
|
||
const chatDot = document.getElementById('chat-dot');
|
||
const chatName = document.getElementById('chat-name');
|
||
const chatStatus = document.getElementById('chat-status');
|
||
const chatTime = document.getElementById('chat-time');
|
||
const chatClose = document.getElementById('chat-close');
|
||
const chatMessages = document.getElementById('chat-messages');
|
||
const chatForm = document.getElementById('chat-form');
|
||
const chatInput = document.getElementById('chat-input');
|
||
|
||
let currentAgent = null;
|
||
let eventSource = null;
|
||
|
||
function addBubble(text, who) {
|
||
const div = document.createElement('div');
|
||
div.className = `chat-bubble ${who}`;
|
||
div.textContent = text;
|
||
chatMessages.appendChild(div);
|
||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||
}
|
||
|
||
function setStatus(status) {
|
||
if (!currentAgent) return;
|
||
currentAgent.status = status;
|
||
const map = { thinking: ['#f59e0b', i18n.chatThinking], responding: ['#f59e0b', i18n.chatResponding], active: ['#22c55e', 'online'], error: ['#ef4444', 'error'] };
|
||
const [color, label] = map[status] || map.active;
|
||
chatDot.style.background = color;
|
||
chatStatus.textContent = label;
|
||
// update sprite label
|
||
const mesh = agentMeshes.find(m => m.userData?.agent?.id === currentAgent.id);
|
||
if (mesh) {
|
||
const old = mesh.userData.label;
|
||
if (old) {
|
||
mesh.remove(old);
|
||
old.material.map?.dispose();
|
||
}
|
||
const l = makeLabel(currentAgent.name, status);
|
||
l.position.y = old ? old.position.y : 2.15;
|
||
mesh.add(l);
|
||
mesh.userData.label = l;
|
||
}
|
||
}
|
||
|
||
function openChat(agent) {
|
||
currentAgent = agent;
|
||
chatPanel.style.display = 'flex';
|
||
chatName.textContent = agent.name;
|
||
chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
chatMessages.innerHTML = '';
|
||
|
||
// Replay history
|
||
const history = agent.last_decision || [];
|
||
history.forEach(m => {
|
||
if (m.role === 'user') addBubble(m.content, 'user');
|
||
else if (m.role === 'assistant') addBubble(m.content, 'npc');
|
||
});
|
||
|
||
setStatus(agent.status || 'active');
|
||
|
||
// Open SSE stream
|
||
if (eventSource) { eventSource.close(); eventSource = null; }
|
||
eventSource = new EventSource(`/city/chat/${agent.id}/stream`);
|
||
eventSource.addEventListener('message', (e) => {
|
||
try {
|
||
const payload = JSON.parse(e.data);
|
||
if (payload.type === 'thinking') {
|
||
setStatus('thinking');
|
||
addBubble(i18n.chatThinking, 'meta');
|
||
} else if (payload.type === 'reply') {
|
||
setStatus('active');
|
||
addBubble(payload.reply, 'npc');
|
||
chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
} else if (payload.type === 'error') {
|
||
setStatus('error');
|
||
addBubble(payload.reply || i18n.chatError, 'meta');
|
||
}
|
||
} catch (_) { /* ignore parse errors */ }
|
||
});
|
||
eventSource.onerror = () => { /* auto-retry handled by EventSource */ };
|
||
}
|
||
|
||
function closeChat() {
|
||
chatPanel.style.display = 'none';
|
||
currentAgent = null;
|
||
if (eventSource) { eventSource.close(); eventSource = null; }
|
||
}
|
||
|
||
chatClose.addEventListener('click', closeChat);
|
||
|
||
chatForm.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const msg = chatInput.value.trim();
|
||
if (!msg || !currentAgent) return;
|
||
chatInput.value = '';
|
||
addBubble(msg, 'user');
|
||
setStatus('thinking');
|
||
try {
|
||
await fetch(`/city/chat/${currentAgent.id}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
|
||
body: JSON.stringify({ message: msg }),
|
||
});
|
||
} catch (_) {
|
||
addBubble(i18n.chatError, 'meta');
|
||
}
|
||
});
|
||
|
||
// ------------------------------------------------------------------
|
||
// HUD position update
|
||
// ------------------------------------------------------------------
|
||
function updateHUD() {
|
||
const g = player.pos.y <= player.eyeHeight + 0.01;
|
||
posXYZ.textContent = `x:${player.pos.x.toFixed(1)} y:${player.pos.y.toFixed(1)} z:${player.pos.z.toFixed(1)}`;
|
||
posGround.textContent = g
|
||
? `${i18n.onGround} · ${player.pos.y.toFixed(1)}m`
|
||
: `airborne · ${player.pos.y.toFixed(1)}m`;
|
||
posTris.textContent = `${colliders.length} ${i18n.tris}`;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Resize + animation loop
|
||
// ------------------------------------------------------------------
|
||
function resize() {
|
||
const w = host.clientWidth, h = host.clientHeight;
|
||
camera.aspect = w / h;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(w, h, false);
|
||
}
|
||
new ResizeObserver(resize).observe(host);
|
||
resize();
|
||
|
||
let lastT = performance.now();
|
||
function animate() {
|
||
requestAnimationFrame(animate);
|
||
const now = performance.now();
|
||
const dt = Math.min(0.05, (now - lastT) / 1000);
|
||
lastT = now;
|
||
|
||
updatePlayer(dt);
|
||
updateCameraFromPlayer();
|
||
updateHUD();
|
||
renderer.render(scene, camera);
|
||
}
|
||
animate();
|
||
|
||
// ------------------------------------------------------------------
|
||
// Data loading
|
||
// ------------------------------------------------------------------
|
||
async function loadCity() {
|
||
loadingEl.style.display = 'grid';
|
||
errorEl.style.display = 'none';
|
||
try {
|
||
const res = await fetch(dataUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-store' });
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
cityData = await res.json();
|
||
buildCity(cityData);
|
||
// Seed agent last_decision from server
|
||
(cityData.agents || []).forEach(a => {
|
||
// placeholder — history is fetched lazily on openChat via SSE
|
||
});
|
||
loadingEl.style.display = 'none';
|
||
} catch (err) {
|
||
console.error(err);
|
||
loadingEl.style.display = 'none';
|
||
errorEl.style.display = 'grid';
|
||
}
|
||
}
|
||
|
||
reloadBtn.addEventListener('click', loadCity);
|
||
loadCity();
|
||
</script>
|
||
@endpush |