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:
2026-10-17 20:46:15 -03:00
commit 79d12fa2f4
128 changed files with 17053 additions and 0 deletions
+995
View File
@@ -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