Files
City10/resources/views/city/viewer.blade.php
T
marcocosta10000 79d12fa2f4 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
2026-10-17 20:46:15 -03:00

995 lines
42 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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