feat: add factory sample machines and robot pricing conversations

This commit is contained in:
2026-10-28 12:53:07 +03:30
parent d0da416923
commit 55d4123fe5
71 changed files with 8179 additions and 3978 deletions
+348
View File
@@ -0,0 +1,348 @@
import * as THREE from 'three';
import data from '../../data/mellat.json';
import { routeBetween, nearestSegment } from '../world/routing.js';
import { FACTORY } from '../../constants/locations.js';
import { isOpen, formatClock, fa } from '../economy/gameState.js';
import { rankFor, activeMission, MISSIONS } from '../economy/scoring.js';
import { kmh } from '../systems/vehicle.js';
import { drawMinimap } from '../world/mapRender.js';
import { bearing, formatDistance } from '../../utils/geo.js';
/**
* Runs the rebuild route mesh operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function rebuildRouteMesh(context) {
if (context.routeMesh) {
context.scene.remove(context.routeMesh);
context.routeMesh.geometry.dispose();
context.routeMesh = null;
}
if (!context.route) return;
const pts = [],
pos = [],
idx = [];
let total = 0;
for (const p of context.route.points) {
if (pts.length) total += Math.hypot(p.x - pts.at(-1).x, p.z - pts.at(-1).z);
if (total > 700) break;
pts.push(p);
}
for (let i = 0; i < pts.length; i++) {
const a = pts[Math.max(0, i - 1)],
b = pts[Math.min(pts.length - 1, i + 1)];
const dx = b.x - a.x,
dz = b.z - a.z,
len = Math.hypot(dx, dz) || 1,
w = context.mode === 'drive' ? 1.1 : 0.55;
pos.push(
pts[i].x - (dz / len) * w,
0.14,
pts[i].z + (dx / len) * w,
pts[i].x + (dz / len) * w,
0.14,
pts[i].z - (dx / len) * w,
);
if (i) idx.push((i - 1) * 2, i * 2, (i - 1) * 2 + 1, (i - 1) * 2 + 1, i * 2, i * 2 + 1);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setIndex(idx);
context.routeMesh = new THREE.Mesh(g, context.routeMaterial);
context.scene.add(context.routeMesh);
}
/**
* Updates route from the active simulation.
* @param {*} context - context.
* @param {*} dt - dt.
* @returns {*} Operation result, when applicable.
*/
export function updateRoute(context, dt) {
context.routeTimer -= dt;
const target = context.objectiveStation();
if (!target) {
if (context.route) {
context.route = null;
context.rebuildRouteMesh();
}
return;
}
const pos = context.mode === 'drive' ? context.car : context.player;
const changed =
target !== context.routeTarget || context.routeMode !== context.mode || !context.route;
if (
!changed &&
(context.routeTimer > 0 ||
Math.hypot(pos.x - context.routeFrom.x, pos.z - context.routeFrom.z) < 10)
)
return;
context.routeTimer = 1.2;
context.routeTarget = target;
context.routeMode = context.mode;
context.routeFrom = {
x: pos.x,
z: pos.z,
};
// Close targets are reached directly; the street network would detour out of buildings.
const direct = Math.hypot(target.front.x - pos.x, target.front.z - pos.z);
context.route =
direct < 60
? {
points: [
{
x: pos.x,
z: pos.z,
},
{
x: target.front.x,
z: target.front.z,
},
],
length: direct,
}
: routeBetween(
context.mode === 'drive' ? context.driveGraph : context.walkGraph,
pos.x,
pos.z,
target.front.x,
target.front.z,
);
context.rebuildRouteMesh();
}
/**
* Runs the draw compass operation for this subsystem.
* @param {*} context - context.
* @param {*} headingDeg - heading Deg.
* @param {*} markers - markers.
* @returns {*} Operation result, when applicable.
*/
export function drawCompass(context, headingDeg, markers) {
const w = 560,
h = 44;
context.compass.clearRect(0, 0, w, h);
context.compass.fillStyle = 'rgba(10,16,14,.45)';
context.compass.fillRect(0, 10, w, 24);
context.compass.textAlign = 'center';
context.compass.textBaseline = 'middle';
const labels = {
0: 'شمال',
90: 'شرق',
180: 'جنوب',
270: 'غرب',
};
for (let d = -100; d <= 100; d += 5) {
const deg = (((Math.round(headingDeg / 5) * 5 + d) % 360) + 360) % 360,
x = w / 2 + ((deg - headingDeg + 540) % 360) * 2.6 - 180 * 2.6;
if (x < 10 || x > w - 10) continue;
context.compass.fillStyle = 'rgba(236,242,238,.8)';
if (labels[deg] !== undefined) {
context.compass.font = 'bold 13px Vazirmatn, sans-serif';
context.compass.fillStyle = deg === 0 ? '#f2c94c' : '#ecf2ee';
context.compass.fillText(labels[deg], x, 22);
} else if (deg % 15 === 0) {
context.compass.font = '10px Vazirmatn, sans-serif';
context.compass.fillText(fa(deg), x, 22);
} else context.compass.fillRect(x - 0.5, 18, 1, 8);
}
for (const m of markers) {
const x = w / 2 + (((m.bearing - headingDeg + 540) % 360) - 180) * 2.6;
if (x < 10 || x > w - 10) continue;
context.compass.save();
context.compass.translate(x, 6);
context.compass.rotate(Math.PI / 4);
context.compass.fillStyle = m.color;
context.compass.fillRect(-4, -4, 8, 8);
context.compass.restore();
}
context.compass.fillStyle = '#ecf2ee';
context.compass.fillRect(w / 2 - 1, 34, 2, 8);
context.compass.font = 'bold 11px Vazirmatn, sans-serif';
context.compass.fillText(fa(Math.round(headingDeg) % 360), w / 2, 6);
}
/**
* Runs the refresh hud operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function refreshHUD(context) {
context.ui.patch('balance', {
text: fa(context.state.balance),
});
const rank = rankFor(context.state.xp);
context.ui.patch('rank-name', {
text: rank.name,
});
context.ui.patch('xp', {
text: rank.next
? `${fa(context.state.xp)} / ${fa(rank.next)} XP`
: `${fa(context.state.xp)} XP`,
});
context.ui.style('xp-bar', {
width: `${Math.round(rank.progress * 100)}%`,
});
const active = activeMission(context.state);
context.ui.patch('mission-list', {
items: MISSIONS.map((m) => ({
id: m.id,
title: m.title,
done: context.state.missionsDone.includes(m.id),
active: m === active,
})),
});
context.ui.publishGame(context.state);
context.ui.patch('mode-label', {
text: context.mode === 'drive' ? 'در ماشین · F برای پیاده شدن' : 'پیاده',
});
context.ui.patch('speed-stat', {
hidden: context.mode !== 'drive',
});
}
/**
* Updates prompt from the active simulation.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function updatePrompt(context) {
const sample = context.nearShowroom();
if (sample && !context.state.busy && context.payment.phase === 'idle') {
context.ui.patch('prompt', {
text: `Q · معرفی ${sample.model.name} و پرسیدن قیمت`,
hidden: false,
});
return;
}
let text = '';
const station = context.nearStation();
if (!context.state.busy && context.payment.phase === 'idle') {
if (station)
text = isOpen(station.def, context.state.minutes)
? `E · خرید از روبات مارکت · ${station.def.name}`
: `دستگاه بسته است · ${context.hoursText(station.def)}`;
else if (
context.mode === 'walk' &&
Math.hypot(context.car.x - context.player.x, context.car.z - context.player.z) < 4.5
)
text = 'F · سوار ماشین شو';
else if (
context.mode === 'drive' &&
Math.abs(context.car.speed) < 1 &&
context.stations.some(
(s) => Math.hypot(s.front.x - context.car.x, s.front.z - context.car.z) < 25,
)
)
text = 'F · پیاده شو؛ دستگاه نزدیک است';
}
context.ui.patch('prompt', {
text,
hidden: !text,
});
}
// ---------- Map dialog ----------
/**
* Updates hud from the active simulation.
* @param {*} context - context.
* @param {*} dt - dt.
* @returns {*} Operation result, when applicable.
*/
export function updateHUD(context, dt) {
context.ui.patch('showroom-open', {
hidden:
context.real.on ||
context.mode !== 'walk' ||
Math.hypot(context.player.x - FACTORY.center.x, context.player.z - FACTORY.center.z) >= 130,
});
const pos = context.mode === 'drive' ? context.car : context.player;
context.ui.patch('clock', {
text: formatClock(context.state.minutes),
});
if (context.mode === 'drive')
context.ui.patch('speed', {
text: fa(kmh(context.car)),
});
// Where the camera actually looks: the orbit camera, or the phone itself in AR view.
const look3 = context.camera.getWorldDirection(new THREE.Vector3()),
flat = Math.hypot(look3.x, look3.z);
const forward =
flat > 0.05
? {
x: look3.x / flat,
z: look3.z / flat,
}
: {
x: -Math.sin(context.cam.yaw),
z: -Math.cos(context.cam.yaw),
};
const headingDeg = bearing(forward.x, forward.z);
const objective = context.objectiveStation();
context.drawCompass(
headingDeg,
objective
? [
{
bearing: bearing(objective.x - pos.x, objective.z - pos.z),
color: '#f2c94c',
},
]
: [],
);
drawMinimap(context.minimap, context.mapAssets, {
size: 220,
x: pos.x,
z: pos.z,
heading: context.mode === 'drive' ? context.car.heading : context.player.heading,
camForward: forward,
radius: context.mode === 'drive' ? 650 : 200,
markers: context.markers(),
route: context.route?.points,
});
// Objective marker above the machine, clamped to the screen edge when off-screen.
context.ui.patch('waypoint', {
hidden: !objective,
});
if (objective) {
const d = Math.hypot(objective.x - pos.x, objective.z - pos.z),
p = new THREE.Vector3(objective.x, 3.4, objective.z).project(context.camera);
let sx = (p.x * 0.5 + 0.5) * innerWidth,
sy = (-p.y * 0.5 + 0.5) * innerHeight;
if (p.z > 1) {
sx = innerWidth - sx;
sy = innerHeight - 60;
}
context.ui.style('waypoint', {
left: `${THREE.MathUtils.clamp(sx, 60, innerWidth - 60)}px`,
});
context.ui.style('waypoint', {
top: `${THREE.MathUtils.clamp(sy, 90, innerHeight - 130)}px`,
});
context.ui.patch('waypoint-name', {
text: objective.def.name,
});
context.ui.patch('waypoint-distance', {
text: formatDistance(d),
});
context.ui.patch('waypoint', {
hidden: d < 6,
});
for (const s of context.stations) {
const isObjective = s === objective;
s.model.beacon.material.color.set(isObjective ? '#f2c94c' : '#3ee0c9');
s.model.beacon.material.opacity =
(isObjective ? 0.32 : 0.14) * Math.min(1, Math.hypot(s.x - pos.x, s.z - pos.z) / 40);
}
}
context.streetTimer -= dt;
if (context.streetTimer <= 0) {
context.streetTimer = 0.6;
const street = nearestSegment(context.segments, pos.x, pos.z, 35, (s) => s[5] >= 0);
const inFactory = Math.hypot(pos.x - FACTORY.center.x, pos.z - FACTORY.center.z) < 130;
context.ui.patch('street', {
text: inFactory
? `کارخانهٔ ${FACTORY.name}`
: street
? `مشهد · ${data.names[street.name]}`
: 'حومهٔ مشهد',
});
}
context.updatePrompt();
}