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:
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user