Files
vending-machine-game/src/factory.js
T
HamedandClaude Opus 5.5 6ce560089f
CI/CD / test (push) Successful in 28s
CI/CD / publish (push) Successful in 17s
CI/CD / deploy (push) Successful in 4s
Add camera AR view, GPS stable mode and wider machine range
- Real-world mode opens the phone's rear camera as the game background;
  the 3D camera stands at the GPS position and follows the phone's
  orientation, so machines, beacons, range circles, the route and the
  robot appear in their real direction. City, traffic and family are
  hidden in this view; a toggle switches back to the 3D map, and drag
  turns the view where there is no orientation sensor
- Stable mode (default on): drops GPS jumps impossible on foot, holds the
  position until it drifts past half the GPS error, calms the compass
- Arrival range is 2.5x the GPS error (20–50 m), shown as a ground circle
- Tests for the stabilizer, AR orientation and field of view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:14:04 +03:30

196 lines
9.7 KiB
JavaScript

import * as THREE from 'three';
import {createVendingMachine} from './vending.js';
import {createCarModel} from './traffic.js';
import {FACTORY} from './locations.js';
import {textTexture} from './textures.js';
function stripes(c, w, h, base, line, every) {
c.fillStyle = base;
c.fillRect(0, 0, w, h);
c.fillStyle = line;
for (let x = 0; x < w; x += every) c.fillRect(x, 0, every * .35, h);
}
/**
* The Robot Market factory compound: production hall (where the game starts),
* office, fenced yard and gate. Layout uses a local frame: u runs along the driveway towards the
* gate, v across it. Returns world-space collision footprints and key positions.
*/
export function createFactory(scene) {
const {center, axis} = FACTORY, normal = {x: -axis.z, z: axis.x};
const rot = Math.atan2(-axis.z, axis.x);
const toWorld = (u, v) => ({x: center.x + axis.x * u + normal.x * v, z: center.z + axis.z * u + normal.z * v});
const group = new THREE.Group();
group.position.set(center.x, 0, center.z);
group.rotation.y = rot;
scene.add(group);
const footprints = [];
// Axis-aligned box in the local frame; solid ones also block movement.
const box = (u0, u1, v0, v1, y0, y1, material, solid = true, parent = group) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(u1 - u0, y1 - y0, v1 - v0), material);
mesh.position.set((u0 + u1) / 2, (y0 + y1) / 2, (v0 + v1) / 2);
mesh.castShadow = mesh.receiveShadow = true;
parent.add(mesh);
if (solid) footprints.push([[u0, v0], [u1, v0], [u1, v1], [u0, v1]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
return mesh;
};
const mat = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .7, ...extra});
// Yard: asphalt, painted driveway, and the concrete fence with a gate onto Kaleh Street.
const yard = new THREE.Mesh(new THREE.PlaneGeometry(190, 100), mat('#8f8c86', {roughness: .95, polygonOffset: true, polygonOffsetFactor: -14, polygonOffsetUnits: -28}));
yard.rotation.x = -Math.PI / 2;
yard.position.set(-15, .08, 0);
yard.receiveShadow = true;
group.add(yard);
const fence = mat('#d9d3c4');
box(-110, 80, -50.3, -49.7, 0, 2.4, fence);
box(-110, 80, 49.7, 50.3, 0, 2.4, fence);
box(-110.3, -109.7, -50, 50, 0, 2.4, fence);
box(79.7, 80.3, -50, -9, 0, 2.4, fence);
box(79.7, 80.3, 9, 50, 0, 2.4, fence);
const teal = mat('#0b8f88', {metalness: .3, roughness: .4}), white = mat('#eef4f2', {roughness: .5});
for (const v of [-9.5, 9.5]) box(79, 81, v - 1, v + 1, 0, 4.2, teal);
// Gate sign with the company name.
const nameSign = textTexture(1024, 320, (c, w, h) => {
c.fillStyle = '#0d2f2c';
c.fillRect(0, 0, w, h);
c.fillStyle = '#3ee0c9';
c.fillRect(0, h - 18, w, 18);
c.textAlign = 'center';
c.direction = 'rtl';
c.fillStyle = '#ffffff';
c.font = 'bold 66px Vazirmatn, Tahoma, sans-serif';
c.fillText(FACTORY.name, w / 2, 120);
c.fillStyle = '#9fe9da';
c.font = '44px Vazirmatn, Tahoma, sans-serif';
c.fillText(FACTORY.tagline, w / 2, 210);
c.font = 'bold 34px sans-serif';
c.fillStyle = '#f2c94c';
c.fillText('ROBOT MARKET · VENDING MACHINES', w / 2, 270);
});
const signMaterial = new THREE.MeshBasicMaterial({map: nameSign, side: THREE.DoubleSide});
const gateSign = new THREE.Mesh(new THREE.PlaneGeometry(14, 4.4), signMaterial);
gateSign.position.set(82, 6.6, -20);
gateSign.rotation.y = Math.PI / 2;
group.add(gateSign);
for (const v of [-25.5, -14.5]) box(81.6, 82.4, v - .3, v + .3, 0, 8.8, mat('#59646b', {metalness: .5}), false);
// Production hall: corrugated walls, teal band, a wide door facing the driveway.
const corrugated = textTexture(256, 64, (c, w, h) => stripes(c, w, h, '#c9d3d6', '#b2bec2', 16));
corrugated.wrapS = corrugated.wrapT = THREE.RepeatWrapping;
corrugated.repeat.set(12, 2);
const wall = mat('#ffffff', {map: corrugated, metalness: .35, roughness: .55});
const H = 10, U0 = -62, U1 = 2, V0 = 15, V1 = 45, D0 = -20, D1 = -4;
box(U0, U1, V1, V1 + .4, 0, H, wall);
box(U0 - .4, U0, V0, V1 + .4, 0, H, wall);
box(U1, U1 + .4, V0, V1 + .4, 0, H, wall);
box(U0 - .4, D0, V0 - .4, V0, 0, H, wall);
box(D1, U1 + .4, V0 - .4, V0, 0, H, wall);
box(D0, D1, V0 - .4, V0, 7, H, wall, false);
box(U0 - .4, U1 + .4, V0 - .5, V0 - .3, H - 1.6, H - .6, teal, false);
const roof = box(U0 - 1, U1 + 1, V0 - 1, V1 + 1, H, H + .4, mat('#9aa6aa', {metalness: .4}), false);
// The roof stays out of the shadow map, so daylight still reaches the hall floor.
roof.castShadow = false;
const floor = new THREE.Mesh(new THREE.PlaneGeometry(U1 - U0, V1 - V0), mat('#b9c4bd', {roughness: .35, polygonOffset: true, polygonOffsetFactor: -16, polygonOffsetUnits: -32}));
floor.rotation.x = -Math.PI / 2;
floor.position.set((U0 + U1) / 2, .1, (V0 + V1) / 2);
floor.receiveShadow = true;
group.add(floor);
const lamp = new THREE.MeshBasicMaterial({color: '#fffbe8'});
for (let u = U0 + 8; u < U1; u += 12) box(u - 3, u + 3, 29, 31, H - .3, H - .1, lamp, false);
const hallSign = new THREE.Mesh(new THREE.PlaneGeometry(16, 5), signMaterial);
hallSign.position.set((D0 + D1) / 2, H + 3, V0 - .6);
hallSign.rotation.y = Math.PI;
group.add(hallSign);
for (const u of [(D0 + D1) / 2 - 7.5, (D0 + D1) / 2 + 7.5]) box(u - .2, u + .2, V0 - .7, V0 - .5, H, H + 5.5, mat('#59646b', {metalness: .5}), false);
// Assembly line: a conveyor carrying machine frames, robot arms, and finished machines in a row.
box(-60, -36, 33, 37, 0, .9, mat('#3d4549', {metalness: .5}));
const rollers = mat('#9aa4a8', {metalness: .8, roughness: .3});
for (let u = -59; u < -36; u += 1.2) {
const roller = new THREE.Mesh(new THREE.CylinderGeometry(.12, .12, 3.8, 8), rollers);
roller.rotation.x = Math.PI / 2;
roller.position.set(u, .95, 35);
group.add(roller);
}
const frames = [], frameMaterial = mat('#0b8f88', {wireframe: true});
for (let i = 0; i < 4; i++) {
const frame = new THREE.Mesh(new THREE.BoxGeometry(1.1, 2.4, 1.8), i % 2 ? frameMaterial : teal);
frame.position.set(-58 + i * 6, 2.2, 35);
frame.castShadow = true;
group.add(frame);
frames.push(frame);
}
const arms = [];
for (const u of [-52, -42]) {
box(u - .6, u + .6, 39, 40.2, 0, 1.2, mat('#f2c94c'));
const arm = new THREE.Group();
arm.position.set(u, 1.2, 39.6);
group.add(arm);
const upper = new THREE.Mesh(new THREE.BoxGeometry(.35, 2.2, .35), mat('#f2c94c'));
upper.position.y = 1.1;
upper.castShadow = true;
const fore = new THREE.Mesh(new THREE.BoxGeometry(.28, .28, 2.4), mat('#e0b23a'));
fore.position.set(0, 2.1, -1.1);
fore.castShadow = true;
arm.add(upper, fore);
arms.push(arm);
}
for (let i = 0; i < 4; i++) {
const finished = createVendingMachine({name: 'آمادهٔ نصب', products: ['water', 'juice', 'biscuit', 'chocolate']});
finished.beacon.visible = finished.ring.visible = false;
finished.group.position.set(-32 + i * 4.2, 0, 43.4);
finished.group.rotation.y = Math.PI;
group.add(finished.group);
footprints.push([[-33 + i * 4.2, 42.6], [-31 + i * 4.2, 42.6], [-31 + i * 4.2, 44.6], [-33 + i * 4.2, 44.6]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
}
// Crates of Robot Market stock along the end wall.
const crate = mat('#b98b55');
for (let i = 0; i < 6; i++) box(-61, -58.6, 17 + i * 2.6, 19.2 + i * 2.6, 0, 1.2 + (i % 3) * 1.1, crate);
// Office block with the company name on its roof.
const windows = textTexture(128, 128, (c, w, h) => {
c.fillStyle = '#e9e4d8';
c.fillRect(0, 0, w, h);
c.fillStyle = '#4c6672';
c.fillRect(18, 22, 92, 70);
c.fillStyle = 'rgba(255,255,255,.25)';
c.fillRect(22, 26, 26, 62);
});
windows.wrapS = windows.wrapT = THREE.RepeatWrapping;
const officeMaterial = mat('#ffffff', {map: windows});
const office = box(8, 32, -34, -22, 0, 9.6, officeMaterial);
office.material = [officeMaterial, officeMaterial, mat('#bdb5a5'), mat('#bdb5a5'), officeMaterial, officeMaterial];
windows.repeat.set(6, 3);
const roofSign = new THREE.Mesh(new THREE.PlaneGeometry(22, 3.4), signMaterial);
roofSign.position.set(20, 11.6, -21.9);
roofSign.rotation.y = Math.PI;
group.add(roofSign);
for (const u of [11, 29]) box(u - .15, u + .15, -22.2, -21.9, 9.6, 13.2, mat('#59646b'), false);
// Staff cars in the car park.
for (let i = 0; i < 4; i++) {
const parked = createCarModel(['#2f4a6b', '#e9e9e4', '#7e1f1d', '#9aa3a8'][i]);
parked.group.position.set(36 + i * 4, 0, -18);
parked.group.rotation.y = Math.PI / 2;
group.add(parked.group);
footprints.push([[34.9 + i * 4, -20.3], [37.1 + i * 4, -20.3], [37.1 + i * 4, -15.7], [34.9 + i * 4, -15.7]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
}
// The demo machine stands inside the hall facing the door; the family starts in front of it.
const demo = toWorld(-8, 41.5), facing = {x: -normal.x, z: -normal.z};
const car = toWorld(52, -6);
return {
group,
footprints,
demo: {x: demo.x, z: demo.z, rot: Math.atan2(facing.x, facing.z)},
car: {x: car.x, z: car.z, heading: Math.atan2(-axis.z, axis.x)},
label: {name: FACTORY.name, kind: 'factory', ...toWorld(-20, 0)},
update(time) {
arms.forEach((arm, i) => arm.rotation.y = Math.sin(time * .8 + i * 2) * .9);
frames.forEach((frame, i) => frame.position.x = -58 + ((time * .6 + i * 6) % 24));
}
};
}