feat(city): add player marketplace, mission journal, character customization and presence

- Player-to-player marketplace with listings, buying and selling
- Mission system with progress tracking and money rewards
- Character appearance customization (skin, hair, face, beard, accessory)
- Multiplayer presence feed showing nearby citizens in near-real-time
- Admin world dashboard with live platform metrics
- Bus and train transportation with temporary ride state
- Mobile joystick controls and minimap HUD
- Education bonus applied to work salary
This commit is contained in:
2026-10-17 22:51:20 -03:00
parent 18778b3d26
commit a620f83dd8
30 changed files with 1822 additions and 10 deletions
+429
View File
@@ -203,6 +203,12 @@ let exercise = {
animPhase: 0,
nearId: null,
};
let remoteCitizens = new Map(); // characterId -> mesh
let presenceTimer = null;
let vehicleHeadlights = [];
let mobileInput = { x: 0, y: 0, active: false };
let minimapCtx = null;
let lastPresenceOk = 0;
function clearGroup(g) {
while (g.children.length) {
@@ -210,6 +216,8 @@ function clearGroup(g) {
c.traverse?.(o => { o.geometry?.dispose?.(); o.material?.dispose?.(); });
}
citizenMeshes = [];
remoteCitizens = new Map();
vehicleHeadlights = [];
}
function addCollider(cx, cy, cz, w, h, d) {
@@ -2849,6 +2857,11 @@ function updatePlayer(dt) {
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 (mobileInput.active) {
// Joystick: +y on screen is forward for the player.
wish.addScaledVector(forward, -mobileInput.y);
wish.addScaledVector(right, mobileInput.x);
}
if (wish.lengthSq() > 0) {
wish.normalize();
@@ -3387,7 +3400,9 @@ function animate() {
updateExercise(dt);
updateCameraFromPlayer();
updateHUD();
updateVehicleHeadlights();
syncPositionThrottled();
drawMinimap();
// Footsteps while the local player is actually walking.
const walking = locked && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
@@ -3404,6 +3419,411 @@ function animate() {
}
animate();
// ------------------------------------------------------------------
// Multiplayer presence (other citizens in near-real-time)
// ------------------------------------------------------------------
function upsertRemoteCitizen(c) {
if (c.is_me) return;
let mesh = remoteCitizens.get(c.id);
if (!mesh) {
mesh = buildHumanoid({
name: c.name,
type: c.type === 'child' ? 'crowd' : 'pedestrian',
status: c.status || 'active',
id: c.id,
}, true);
const [cx, , cz] = c.position;
mesh.position.set(cx, 0, cz);
worldGroup.add(mesh);
crowdMeshes.push(mesh);
remoteCitizens.set(c.id, mesh);
} else {
const [cx, , cz] = c.position;
const dx = cx - mesh.position.x;
const dz = cz - mesh.position.z;
mesh.position.x = cx;
mesh.position.z = cz;
if (Math.hypot(dx, dz) > 0.05) {
mesh.rotation.y = Math.atan2(dx, dz);
animateHumanoid(mesh, 0.016, Math.min(2, Math.hypot(dx, dz) * 4));
}
}
}
async function pollPresence() {
if (!window.__city10?.ready || hospitalBusy) return;
try {
const qs = cfg.citySlug ? `?city=${encodeURIComponent(cfg.citySlug)}` : '';
const res = await fetch(`/city/presence${qs}`, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
cache: 'no-store',
});
if (!res.ok) return;
const data = await res.json();
const seen = new Set();
for (const c of data.citizens || []) {
if (c.is_me) continue;
seen.add(c.id);
upsertRemoteCitizen(c);
}
for (const [id, mesh] of remoteCitizens) {
if (!seen.has(id)) {
worldGroup.remove(mesh);
remoteCitizens.delete(id);
}
}
lastPresenceOk = performance.now();
} catch (_) { /* offline-safe */ }
}
function startPresenceLoop() {
if (presenceTimer) clearInterval(presenceTimer);
presenceTimer = setInterval(pollPresence, 2500);
pollPresence();
}
// ------------------------------------------------------------------
// Minimap (canvas overlay)
// ------------------------------------------------------------------
function initMinimap() {
const canvas = document.getElementById('minimap');
if (!canvas) return;
minimapCtx = canvas.getContext('2d');
}
function drawMinimap() {
if (!minimapCtx || !cityBounds) return;
const canvas = minimapCtx.canvas;
const w = canvas.width;
const h = canvas.height;
const minX = cityBounds.min_x;
const maxX = cityBounds.max_x;
const minZ = cityBounds.min_z;
const maxZ = cityBounds.max_z;
const spanX = Math.max(1, maxX - minX);
const spanZ = Math.max(1, maxZ - minZ);
const toMap = (x, z) => [
((x - minX) / spanX) * (w - 8) + 4,
((z - minZ) / spanZ) * (h - 8) + 4,
];
minimapCtx.clearRect(0, 0, w, h);
minimapCtx.fillStyle = 'rgba(11,18,32,.85)';
minimapCtx.fillRect(0, 0, w, h);
// Roads
if (grid) {
minimapCtx.strokeStyle = 'rgba(148,163,184,.45)';
minimapCtx.lineWidth = 2;
for (const vx of grid.xs) {
const [mx] = toMap(vx, minZ);
minimapCtx.beginPath();
minimapCtx.moveTo(mx, 4);
minimapCtx.lineTo(mx, h - 4);
minimapCtx.stroke();
}
for (const vz of grid.zs) {
const [, mz] = toMap(minX, vz);
minimapCtx.beginPath();
minimapCtx.moveTo(4, mz);
minimapCtx.lineTo(w - 4, mz);
minimapCtx.stroke();
}
}
// Plazas (static parks — green)
for (const p of plazas) {
const [mx, mz] = toMap(p.position[0], p.position[1]);
minimapCtx.fillStyle = 'rgba(34,197,94,.55)';
minimapCtx.beginPath();
minimapCtx.arc(mx, mz, 5, 0, Math.PI * 2);
minimapCtx.fill();
}
// Stores (static shops — orange)
for (const s of cityData?.stores || []) {
const [mx, mz] = toMap(s.position[0], s.position[2]);
minimapCtx.fillStyle = '#f59e0b';
minimapCtx.fillRect(mx - 2.5, mz - 2.5, 5, 5);
}
// NPC pedestrians (local crowd — yellow, they walk)
if (pedestrianSystem) {
minimapCtx.fillStyle = '#facc15';
for (const p of pedestrianSystem.positions()) {
const [mx, mz] = toMap(p.x, p.z);
minimapCtx.beginPath();
minimapCtx.arc(mx, mz, 1.8, 0, Math.PI * 2);
minimapCtx.fill();
}
}
// Vehicles (moving — colored by kind)
if (vehicleSystem?.vehicles) {
for (const v of vehicleSystem.vehicles) {
const p = { x: v.mesh?.position?.x, z: v.mesh?.position?.z };
if (p.x == null || p.z == null) continue;
const kind = v.kind || v.mesh?.userData?.kind || 'car';
minimapCtx.fillStyle = kind === 'moto' ? '#a78bfa'
: kind === 'bike' ? '#4ade80'
: kind === 'bus' ? '#60a5fa'
: '#f472b6';
// Player is the bright pink; other cars use a softer white-blue.
if (Math.hypot(p.x - player.pos.x, p.z - player.pos.z) < 0.5) continue;
minimapCtx.fillStyle = kind === 'moto' ? '#a78bfa'
: kind === 'bike' ? '#4ade80'
: kind === 'bus' ? '#60a5fa'
: '#94a3b8';
const [mx, mz] = toMap(p.x, p.z);
minimapCtx.fillRect(mx - 1.2, mz - 1.2, 2.4, 2.4);
}
}
// Remote citizens (online players — cyan)
minimapCtx.fillStyle = '#38bdf8';
for (const mesh of remoteCitizens.values()) {
const [mx, mz] = toMap(mesh.position.x, mesh.position.z);
minimapCtx.beginPath();
minimapCtx.arc(mx, mz, 2.8, 0, Math.PI * 2);
minimapCtx.fill();
}
// Player (you — pink with white ring)
const [px, pz] = toMap(player.pos.x, player.pos.z);
minimapCtx.fillStyle = '#f472b6';
minimapCtx.beginPath();
minimapCtx.arc(px, pz, 4, 0, Math.PI * 2);
minimapCtx.fill();
minimapCtx.strokeStyle = '#fff';
minimapCtx.lineWidth = 1.5;
minimapCtx.stroke();
}
// ------------------------------------------------------------------
// Vehicle headlights at night
// ------------------------------------------------------------------
function initVehicleHeadlights() {
for (const v of vehicleSystem?.vehicles || []) {
if (!v.mesh || v.mesh.userData.headlights) continue;
if (v.mesh.userData.kind === 'bike') continue;
const lights = [];
const halfL = v.mesh.userData.halfL ?? 2;
for (const side of [-1, 1]) {
const lamp = new THREE.Mesh(
new THREE.SphereGeometry(0.12, 8, 8),
new THREE.MeshStandardMaterial({
color: 0xfff6c8,
emissive: 0xfff2b0,
emissiveIntensity: 0,
})
);
lamp.position.set(halfL + 0.05, 0.55, side * 0.55);
v.mesh.add(lamp);
lights.push(lamp.material);
}
v.mesh.userData.headlights = lights;
vehicleHeadlights.push(...lights);
}
}
function updateVehicleHeadlights() {
const night = sim.nightFactor;
const on = night > 0.4 ? (night - 0.4) / 0.6 * 2.2 : 0.05;
for (const m of vehicleHeadlights) {
m.emissiveIntensity = on;
}
}
// ------------------------------------------------------------------
// Mobile virtual joystick
// ------------------------------------------------------------------
function bindMobileJoystick() {
const base = document.getElementById('joystick-base');
const knob = document.getElementById('joystick-knob');
if (!base || !knob) return;
let activeId = null;
const radius = 42;
const setKnob = (dx, dy) => {
const len = Math.hypot(dx, dy) || 1;
const clamped = Math.min(len, radius);
const nx = (dx / len) * clamped;
const ny = (dy / len) * clamped;
knob.style.transform = `translate(${nx}px, ${ny}px)`;
mobileInput.x = nx / radius;
mobileInput.y = ny / radius;
mobileInput.active = Math.hypot(mobileInput.x, mobileInput.y) > 0.15;
};
const center = () => {
activeId = null;
mobileInput = { x: 0, y: 0, active: false };
knob.style.transform = 'translate(0px, 0px)';
};
base.addEventListener('pointerdown', (e) => {
activeId = e.pointerId;
base.setPointerCapture(e.pointerId);
const rect = base.getBoundingClientRect();
setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2));
e.preventDefault();
});
base.addEventListener('pointermove', (e) => {
if (activeId !== e.pointerId) return;
const rect = base.getBoundingClientRect();
setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2));
});
base.addEventListener('pointerup', center);
base.addEventListener('pointercancel', center);
}
// ------------------------------------------------------------------
// Market / missions / customize / ride UI
// ------------------------------------------------------------------
async function refreshMarketPanel() {
const listEl = document.getElementById('market-list');
const missionEl = document.getElementById('mission-list');
if (!listEl && !missionEl) return;
try {
const res = await fetch('/city/market', { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
if (listEl) {
listEl.innerHTML = (data.listings || []).length
? data.listings.map(l => `
<div style="display:flex; justify-content:space-between; gap:.4rem; align-items:center; margin:.25rem 0;">
<span>${l.qty}× ${l.item} · $${Number(l.unit_price).toFixed(2)} <span style="opacity:.7">(${l.seller_name})</span></span>
${l.is_mine ? '' : `<button type="button" class="btn market-buy" data-id="${l.id}" style="padding:.15rem .4rem; font-size:.7rem;">${i18n.marketBuy || 'Buy'}</button>`}
</div>`).join('')
: `<div style="opacity:.6;">—</div>`;
listEl.querySelectorAll('.market-buy').forEach(btn => {
btn.addEventListener('click', async () => {
try {
const out = await postJson('/city/market/buy', { listing_id: Number(btn.dataset.id) });
flashCharMsg(out.message || i18n.actionOk);
cityAudio.play('money');
if (out.inventory && meCharacter) {
meCharacter.money = out.money;
meCharacter.inventory = out.inventory;
updateCharacterHud(meCharacter);
}
refreshMarketPanel();
} catch (e) {
flashCharMsg(e.message, true);
}
});
});
}
if (missionEl) {
missionEl.innerHTML = (data.missions || []).map(m => `
<div style="margin:.3rem 0; ${m.is_completed ? 'opacity:.55;' : ''}">
<strong style="color:#fff;">${m.title}</strong>
<div style="font-size:.75rem;">${m.description}</div>
<div style="font-size:.75rem;">${m.progress}/${m.target} · $${Number(m.reward).toFixed(2)}
${m.is_completed ? '✓' : ''}</div>
</div>`).join('') || '<div style="opacity:.6;">—</div>';
}
} catch (_) { /* ignore */ }
}
function bindWorldUI() {
document.getElementById('btn-market')?.addEventListener('click', () => {
const panel = document.getElementById('market-panel');
if (!panel) return;
const show = panel.style.display === 'none' || panel.style.display === '';
panel.style.display = show ? 'block' : 'none';
if (show) refreshMarketPanel();
});
document.getElementById('market-sell')?.addEventListener('click', async () => {
const item = document.getElementById('market-item')?.value || 'product';
const qty = Number(document.getElementById('market-qty')?.value || 1);
const price = Number(document.getElementById('market-price')?.value || 5);
try {
const out = await postJson('/city/market/sell', { item, qty, unit_price: price });
flashCharMsg(out.message || i18n.actionOk);
cityAudio.play('money');
refreshMarketPanel();
} catch (e) {
flashCharMsg(e.message, true);
}
});
document.getElementById('btn-customize')?.addEventListener('click', async () => {
const panel = document.getElementById('customize-panel');
if (!panel) return;
const show = panel.style.display === 'none' || panel.style.display === '';
panel.style.display = show ? 'block' : 'none';
if (!show) return;
try {
const res = await fetch('/city/customize', { headers: { Accept: 'application/json' } });
if (!res.ok) return;
const data = await res.json();
['skin', 'hair', 'shirt', 'face', 'beard', 'accessory'].forEach(k => {
const el = document.getElementById(`cz-${k}`);
if (el) el.value = String(data[k]);
});
} catch (_) { /* ignore */ }
});
document.getElementById('customize-save')?.addEventListener('click', async () => {
const body = {};
for (const k of ['skin', 'hair', 'shirt', 'face', 'beard', 'accessory']) {
body[k] = Number(document.getElementById(`cz-${k}`)?.value || 1);
}
try {
const res = await fetch('/city/customize', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrf,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(body),
});
const out = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(out.message || 'error');
flashCharMsg(out.message || i18n.actionOk);
cityAudio.play('ok');
loadCity();
} catch (e) {
flashCharMsg(e.message, true);
}
});
document.getElementById('btn-ride-bus')?.addEventListener('click', async () => {
try {
const out = await postJson('/city/actions/ride', {
kind: 'bus',
pos_x: player.pos.x,
pos_z: player.pos.z,
});
if (out.character) updateCharacterHud(out.character);
flashCharMsg(out.message || i18n.actionOk);
cityAudio.play('ok');
} catch (e) {
flashCharMsg(e.message, true);
}
});
document.getElementById('btn-ride-train')?.addEventListener('click', async () => {
try {
const out = await postJson('/city/actions/ride', {
kind: 'train',
pos_x: player.pos.x,
pos_z: cityBounds ? cityBounds.max_z + 14 : player.pos.z,
});
if (out.character) updateCharacterHud(out.character);
flashCharMsg(out.message || i18n.actionOk);
cityAudio.play('ok');
} catch (e) {
flashCharMsg(e.message, true);
}
});
}
// ------------------------------------------------------------------
// Test/debug API
// ------------------------------------------------------------------
@@ -3517,6 +3937,9 @@ window.__city10 = {
}
},
action: runAction,
presence: { poll: pollPresence, remotes: () => remoteCitizens.size, lastOk: () => lastPresenceOk },
minimap: () => !!minimapCtx,
joystickBound: () => !!document.getElementById('joystick-base'),
agentPositions() {
return agentMeshes.map(m => ({
id: m.userData?.agent?.id ?? null,
@@ -3544,6 +3967,9 @@ async function loadCity() {
updateDayNight();
loadingEl.style.display = 'none';
window.__city10.ready = true;
initVehicleHeadlights();
startPresenceLoop();
refreshMarketPanel();
} catch (err) {
console.error(err);
loadingEl.style.display = 'none';
@@ -3558,6 +3984,9 @@ reloadBtn.addEventListener('click', () => {
});
bindCharacterUI();
bindAudioUI();
bindWorldUI();
initMinimap();
bindMobileJoystick();
// Unlock audio on the first gesture anywhere (autoplay policy).
const unlockAudio = () => {