feat: add City10 platform with Laravel 13, AI agents, 3D viewer, and LGPD compliance
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
This commit is contained in:
@@ -0,0 +1,995 @@
|
||||
@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
|
||||
Reference in New Issue
Block a user