feat: About Us boards, Mr Mohammadi guide and products-hall orders

- Information boards in the factory yard (about, contact, address) painted from the
  company config; E opens the same sections the start screen shows.
- Mr Mohammadi welcomes the family at his desk on every visit with the company story;
  meeting him is a mission step.
- Products hall gets a purchase-request button and a sales contact link.
- New missions: About Us tour (read, meet, read) and products tour (view, ask, request).
  The About and Products start modes lead with their tour.
- Factory points share the station shape so the arrow, route and chip point at them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 13:09:05 +03:00
co-authored by Claude Opus 5.5
parent bced4dc6f6
commit 71ae4ffa9b
28 changed files with 667 additions and 68 deletions
+28
View File
@@ -0,0 +1,28 @@
// Places in the factory that missions can point at besides vending machines: information boards
// about the company, the manager's desk and the products hall. Positions use the factory's local
// frame (u along the driveway towards the gate, v across it; see game/models/factory.js).
// Boards stand in a row in the yard, left of the product samples, facing the family's start spot.
// `topic` is the company information section the board shows (see startScreen/InfoSectionContent).
export const ABOUT_BOARDS = [
{ id: 'board-about', topic: 'about', u: -32, v: -15 },
{ id: 'board-contact', topic: 'contact', u: -27, v: -15 },
{ id: 'board-address', topic: 'address', u: -22, v: -15 },
];
// Mr Mohammadi's desk in the management room, where he sits once he has arrived.
export const MANAGER_POINT = { id: 'mohammadi', u: 45, v: -40.7 };
// The products hall: the row of machine samples next to the boards.
export const SHOWROOM_POINT_ID = 'showroom';
export const FACTORY_POINT_IDS = [
...ABOUT_BOARDS.map((board) => board.id),
MANAGER_POINT.id,
SHOWROOM_POINT_ID,
];
// How close (metres from the board front) the player must stand to read a board.
export const BOARD_REACH = 3;
// How close to the manager counts as meeting him; matches his talk radius in the factory.
export const MEET_RADIUS = 3.4;
+3 -1
View File
@@ -1,4 +1,5 @@
import { strings } from '../../i18n/fa.js'; import { strings } from '../../i18n/fa.js';
import { missionOrder } from '../missions/missions.js';
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial. // Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
export const INITIAL_BALANCE = 500; export const INITIAL_BALANCE = 500;
export const START_MINUTES = 8 * 60 + 30; export const START_MINUTES = 8 * 60 + 30;
@@ -123,7 +124,8 @@ export function createState() {
purchases: [], purchases: [],
xp: 0, xp: 0,
missionsDone: [], missionsDone: [],
// Current mission and step (see game/missions). // Mission order for the start mode, and the current mission and step (see game/missions).
missionOrder: missionOrder(),
missionIndex: 0, missionIndex: 0,
missionStep: 0, missionStep: 0,
minutes: START_MINUTES, minutes: START_MINUTES,
+7 -2
View File
@@ -1,4 +1,4 @@
import { MISSIONS } from './missions.js'; import { missionById } from './missions.js';
// Pure mission bookkeeping on the game state: which step is current, and what an event (arriving, // Pure mission bookkeeping on the game state: which step is current, and what an event (arriving,
// buying, inspecting…) completes. No rendering or timing here; the runtime reports events. // buying, inspecting…) completes. No rendering or timing here; the runtime reports events.
@@ -10,6 +10,11 @@ const EVENT_FOR_STEP = {
inspect: 'inspect', inspect: 'inspect',
order: 'order', order: 'order',
buy: 'buy', buy: 'buy',
read: 'read',
meet: 'meet',
view: 'view',
ask: 'ask',
request: 'request',
}; };
/** /**
@@ -17,7 +22,7 @@ const EVENT_FOR_STEP = {
* @param {object} state - Game state. * @param {object} state - Game state.
* @returns {object | null} Mission, or null when all are done. * @returns {object | null} Mission, or null when all are done.
*/ */
export const currentMission = (state) => MISSIONS[state.missionIndex] ?? null; export const currentMission = (state) => missionById(state.missionOrder[state.missionIndex]);
/** /**
* The step the player is working on. * The step the player is working on.
+10 -3
View File
@@ -1,7 +1,7 @@
import { machineById } from '../../constants/locations.js'; import { machineById } from '../../constants/locations.js';
import { strings } from '../../i18n/fa.js'; import { strings } from '../../i18n/fa.js';
import { fa } from '../economy/gameState.js'; import { fa } from '../economy/gameState.js';
import { MISSIONS } from './missions.js'; import { missionById } from './missions.js';
import { cityPlacesVisited, currentStep } from './missionProgress.js'; import { cityPlacesVisited, currentStep } from './missionProgress.js';
/** /**
@@ -16,9 +16,16 @@ export function stepText(step, state) {
fa(Math.min(step.count, cityPlacesVisited(state.purchases))), fa(Math.min(step.count, cityPlacesVisited(state.purchases))),
fa(step.count), fa(step.count),
); );
return strings.steps[step.type](machineById(step.target)?.name ?? ''); return strings.steps[step.type](targetName(step.target));
} }
/**
* Display name of a step target: a machine site or a factory point.
* @param {string} [target] - Machine or factory point id.
* @returns {string} Name, or an empty string for steps without a target.
*/
export const targetName = (target) => machineById(target)?.name ?? strings.points[target] ?? '';
/** /**
* The current instruction, or a note that everything is done. * The current instruction, or a note that everything is done.
* @param {object} state - Game state. * @param {object} state - Game state.
@@ -36,7 +43,7 @@ export function currentStepText(state) {
* @returns {object[]} List items for ViewLists. * @returns {object[]} List items for ViewLists.
*/ */
export function missionListItems(state) { export function missionListItems(state) {
return MISSIONS.map((mission, index) => ({ return state.missionOrder.map(missionById).map((mission, index) => ({
id: mission.id, id: mission.id,
title: strings.missions[mission.id], title: strings.missions[mission.id],
done: state.missionsDone.includes(mission.id), done: state.missionsDone.includes(mission.id),
+48
View File
@@ -10,6 +10,14 @@
// - order: send an order request from the machine card of `target`. // - order: send an order request from the machine card of `target`.
// - buy: buy anything from `target`. // - buy: buy anything from `target`.
// - places: buy from `count` different machines in the city (not the factory). // - places: buy from `count` different machines in the city (not the factory).
// - read: read the information board `target` in the factory yard.
// - meet: meet Mr Mohammadi (`target`) at his desk; he tells the company story.
// - view: open the products hall (`target` is 'showroom').
// - ask: ask the robot in the products hall about a model's price or features.
// - request: send a purchase request for a model from the products hall.
//
// Targets are machine ids (constants/locations.js) or factory point ids
// (constants/factoryPoints.js).
export const MISSIONS = [ export const MISSIONS = [
{ {
@@ -20,6 +28,24 @@ export const MISSIONS = [
], ],
reward: { xp: 100, hetoman: 20 }, reward: { xp: 100, hetoman: 20 },
}, },
{
id: 'aboutTour',
steps: [
{ type: 'read', target: 'board-about' },
{ type: 'meet', target: 'mohammadi' },
{ type: 'read', target: 'board-contact' },
],
reward: { xp: 150, hetoman: 20 },
},
{
id: 'productsTour',
steps: [
{ type: 'view', target: 'showroom' },
{ type: 'ask', target: 'showroom' },
{ type: 'request', target: 'showroom' },
],
reward: { xp: 150, hetoman: 20 },
},
{ {
id: 'parkGate', id: 'parkGate',
steps: [ steps: [
@@ -65,3 +91,25 @@ export const MISSIONS = [
export const ARRIVE_RADIUS = 12; export const ARRIVE_RADIUS = 12;
export const INSPECT_RADIUS = 6; export const INSPECT_RADIUS = 6;
export const INSPECT_SECONDS = 3; export const INSPECT_SECONDS = 3;
// The mission each start mode leads with; the others follow in list order.
const LEAD_MISSION = { about: 'aboutTour', products: 'productsTour' };
/**
* Mission ids in the order a start mode plays them: its lead mission first, then the rest.
* @param {string} [mode] - Start mode; unknown or missing modes use the list order.
* @returns {string[]} Mission ids.
*/
export function missionOrder(mode) {
const ids = MISSIONS.map((mission) => mission.id);
const lead = LEAD_MISSION[mode];
if (!lead) return ids;
return [lead, ...ids.filter((id) => id !== lead)];
}
/**
* Finds a mission by id.
* @param {string} id - Mission id.
* @returns {object | null} The mission, or null.
*/
export const missionById = (id) => MISSIONS.find((mission) => mission.id === id) ?? null;
+166
View File
@@ -0,0 +1,166 @@
import * as THREE from 'three';
import { COMPANY } from '../../config/company.js';
import { ABOUT_BOARDS, BOARD_REACH } from '../../constants/factoryPoints.js';
import { strings } from '../../i18n/fa.js';
import { FACTORY_ROTATION, factoryFootprint, factoryToWorld } from '../world/factoryFrame.js';
import { textTexture } from './textures.js';
// Free-standing information boards in the factory yard: history and values, contact details and
// the address. The text is painted from the company config, so it updates with real content.
const BRAND = COMPANY.colors;
// Board face in metres, and the canvas painted on it (same aspect ratio).
const BOARD_WIDTH = 3;
const BOARD_HEIGHT = 1.9;
const CANVAS_WIDTH = 900;
const CANVAS_HEIGHT = 570;
// Height of the board's centre, so it reads at eye level from a few metres away.
const BOARD_CENTRE_Y = 1.75;
const PADDING = 40;
const TITLE_BAND = 110;
const BODY_LINE = 52;
const FONT = 'Vazirmatn, Tahoma, sans-serif';
// How far in front of the board the player stands to read it (local +v).
const FRONT_DISTANCE = 2;
/**
* The text lines a board shows for its topic.
* @param {string} topic - Information section: 'about', 'contact' or 'address'.
* @returns {string[]} Paragraphs; long ones are wrapped when painted.
*/
export function boardLines(topic) {
const { contact, about } = COMPANY;
const labels = strings.landing;
if (topic === 'about')
return [about.history, ...about.values.map((value) => `${strings.boards.bullet} ${value}`)];
if (topic === 'contact')
return [
`${labels.phone}: ${contact.phone}`,
`${labels.email}: ${contact.email}`,
`${labels.website}: ${contact.website.replace(/^https?:\/\//, '')}`,
];
return [labels.fullAddress(contact.address, contact.city)];
}
/**
* Splits a paragraph into lines that fit a width on a canvas.
* @param {CanvasRenderingContext2D} c - Canvas context with the font already set.
* @param {string} text - Paragraph.
* @param {number} maxWidth - Line width in pixels.
* @returns {string[]} Lines.
*/
function wrapLines(c, text, maxWidth) {
const lines = [];
let line = '';
for (const word of text.split(' ')) {
const next = line ? `${line} ${word}` : word;
if (line && c.measureText(next).width > maxWidth) {
lines.push(line);
line = word;
} else line = next;
}
if (line) lines.push(line);
return lines;
}
/**
* Paints a board: a brand-coloured title band, the wrapped text and a hint at the bottom.
* @param {string} topic - Information section.
* @returns {THREE.CanvasTexture} Board texture.
*/
function boardTexture(topic) {
return textTexture(CANVAS_WIDTH, CANVAS_HEIGHT, (c, w, h) => {
c.fillStyle = BRAND.surfaceRaised;
c.fillRect(0, 0, w, h);
c.fillStyle = BRAND.primary;
c.fillRect(0, 0, w, TITLE_BAND);
c.direction = 'rtl';
c.textAlign = 'right';
c.fillStyle = BRAND.onPrimary;
c.font = `bold 64px ${FONT}`;
c.fillText(strings.boards.titles[topic], w - PADDING, 80);
c.fillStyle = BRAND.text;
c.font = `36px ${FONT}`;
const lines = boardLines(topic).flatMap((text) => wrapLines(c, text, w - PADDING * 2));
lines.forEach((line, i) => c.fillText(line, w - PADDING, TITLE_BAND + 70 + i * BODY_LINE));
c.fillStyle = BRAND.primaryLighter;
c.font = `28px ${FONT}`;
c.fillText(strings.boards.readMore, w - PADDING, h - 30);
});
}
/**
* Builds the board mesh: two posts and a framed face.
* @param {string} topic - Information section.
* @returns {THREE.Group} Board model, facing local +Z.
*/
function boardModel(topic) {
const group = new THREE.Group();
const steel = new THREE.MeshStandardMaterial({ color: BRAND.primaryDarker, roughness: 0.5 });
const top = BOARD_CENTRE_Y + BOARD_HEIGHT / 2;
for (const side of [-1, 1]) {
const post = new THREE.Mesh(new THREE.BoxGeometry(0.12, top, 0.12), steel);
post.position.set((side * (BOARD_WIDTH + 0.1)) / 2, top / 2, 0);
group.add(post);
}
const frame = new THREE.Mesh(
new THREE.BoxGeometry(BOARD_WIDTH + 0.16, BOARD_HEIGHT + 0.16, 0.08),
steel,
);
frame.position.y = BOARD_CENTRE_Y;
const face = new THREE.Mesh(
new THREE.PlaneGeometry(BOARD_WIDTH, BOARD_HEIGHT),
new THREE.MeshBasicMaterial({ map: boardTexture(topic) }),
);
face.position.set(0, BOARD_CENTRE_Y, 0.05);
group.add(frame, face);
group.traverse((mesh) => (mesh.castShadow = true));
return group;
}
/**
* Places one board in the yard.
* @param {THREE.Scene} scene - Scene to add it to.
* @param {{id: string, topic: string, u: number, v: number}} board - Board definition.
* @returns {object} Board with world position, reading spot, collision outline and objective info.
*/
function placeBoard(scene, { id, topic, u, v }) {
const model = boardModel(topic);
const position = factoryToWorld(u, v);
model.position.set(position.x, 0, position.z);
model.rotation.y = FACTORY_ROTATION;
model.name = id;
scene.add(model);
return {
id,
topic,
x: position.x,
z: position.z,
front: factoryToWorld(u, v + FRONT_DISTANCE),
footprint: factoryFootprint({ u, v, halfU: BOARD_WIDTH / 2 + 0.1, halfV: 0.15 }),
// Same shape as a machine station's `def`, so boards can be mission objectives.
def: { id, name: strings.points[id], poi: true },
};
}
/**
* Builds the information boards in the factory yard.
* @param {THREE.Scene} scene - Scene to add them to.
* @returns {{boards: object[], nearest: function}} Boards and a lookup for the one in reach.
*/
export function createAboutBoards(scene) {
const boards = ABOUT_BOARDS.map((board) => placeBoard(scene, board));
return {
boards,
/**
* The board the player can read from where they stand.
* @param {{x: number, z: number}} player - Player position.
* @returns {object | null} Closest board within reach, or null.
*/
nearest(player) {
const distance = (board) => Math.hypot(board.front.x - player.x, board.front.z - player.z);
const inReach = boards.filter((board) => distance(board) < BOARD_REACH);
return inReach.sort((a, b) => distance(a) - distance(b))[0] ?? null;
},
};
}
+4
View File
@@ -5,6 +5,7 @@ import { createLuxuryCar } from './luxuryCars.js';
import { MANAGER_CAR_BRAND, VEHICLE_BLACK } from '../../constants/vehicles.js'; import { MANAGER_CAR_BRAND, VEHICLE_BLACK } from '../../constants/vehicles.js';
import { createPerson, createCrew, nameTag } from './staff.js'; import { createPerson, createCrew, nameTag } from './staff.js';
import { FACTORY } from '../../constants/locations.js'; import { FACTORY } from '../../constants/locations.js';
import { MANAGER_POINT } from '../../constants/factoryPoints.js';
import { textTexture } from './textures.js'; import { textTexture } from './textures.js';
import { strings } from '../../i18n/fa.js'; import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.js'; import { COMPANY } from '../../config/company.js';
@@ -1640,6 +1641,9 @@ function factoryBuildArrival(build) {
sat: false, sat: false,
}; };
build.talks.push({ build.talks.push({
// He welcomes the family on every visit; meeting him is an About Us mission step.
id: MANAGER_POINT.id,
repeat: true,
speaker: 'mohammadi', speaker: 'mohammadi',
label: strings.factory.manager.name, label: strings.factory.manager.name,
radius: 3.4, radius: 3.4,
+5 -22
View File
@@ -1,19 +1,7 @@
import { FACTORY } from '../../constants/locations.js';
import { SHOWROOM_CATALOG, SHOWROOM_REACH } from '../../constants/showroomCatalog.js'; import { SHOWROOM_CATALOG, SHOWROOM_REACH } from '../../constants/showroomCatalog.js';
import { createVendingMachine } from './vending.js'; import { createVendingMachine } from './vending.js';
import { createFamily } from './characters.js'; import { createFamily } from './characters.js';
import { FACTORY_ROTATION, factoryFootprint, factoryToWorld } from '../world/factoryFrame.js';
/** Projects factory-local coordinates into the shared world metre system.
* @param {number} u - Position along the factory axis.
* @param {number} v - Position normal to the factory axis.
* @returns {{x:number,z:number}} World position.
*/
function showroomPosition(u, v) {
return {
x: FACTORY.center.x + FACTORY.axis.x * u - FACTORY.axis.z * v,
z: FACTORY.center.z + FACTORY.axis.z * u + FACTORY.axis.x * v,
};
}
/** Creates one display-only sample, with a footprint and interaction position. /** Creates one display-only sample, with a footprint and interaction position.
* @param {{scene:object,model:object,index:number}} options - Sample placement context. * @param {{scene:object,model:object,index:number}} options - Sample placement context.
@@ -27,19 +15,14 @@ function createSample({ scene, model, index }) {
channels: model.channels, channels: model.channels,
products: ['water', 'juice', 'biscuit', 'chocolate'], products: ['water', 'juice', 'biscuit', 'chocolate'],
}); });
const position = showroomPosition(u, v), const position = factoryToWorld(u, v),
front = showroomPosition(u, v + 2); front = factoryToWorld(u, v + 2);
machine.group.name = `showroom-${model.id}`; machine.group.name = `showroom-${model.id}`;
machine.group.position.set(position.x, 0, position.z); machine.group.position.set(position.x, 0, position.z);
machine.group.rotation.y = Math.atan2(-FACTORY.axis.z, FACTORY.axis.x); machine.group.rotation.y = FACTORY_ROTATION;
machine.beacon.visible = machine.ring.visible = false; machine.beacon.visible = machine.ring.visible = false;
scene.add(machine.group); scene.add(machine.group);
const footprint = [ const footprint = factoryFootprint({ u, v, halfU: 1, halfV: 0.6 });
[-1, -0.6],
[1, -0.6],
[1, 0.6],
[-1, 0.6],
].flatMap(([x, z]) => Object.values(showroomPosition(u + x, v + z)));
return { return {
model, model,
machine, machine,
+75
View File
@@ -0,0 +1,75 @@
import { MANAGER_POINT, SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js';
import { PRODUCTS_SAMPLE } from '../../constants/startModes.js';
import { strings } from '../../i18n/fa.js';
import { createAboutBoards } from '../models/aboutBoards.js';
import { addPolygon } from '../world/collision.js';
import { factoryToWorld } from '../world/factoryFrame.js';
// Factory places that missions can point at besides machines (boards, the manager's desk and the
// products hall), and reading the information boards. Every point has the same shape as a machine
// station ({x, z, front, def}) so the objective arrow, route and chip treat them alike.
/**
* Builds a point that is not a board.
* @param {string} id - Point id.
* @param {{x: number, z: number}} at - Where the arrow hangs.
* @param {{x: number, z: number}} front - Where the route leads.
* @returns {object} Objective point.
*/
const point = (id, at, front) => ({
x: at.x,
z: at.z,
front,
def: { id, name: strings.points[id], poi: true },
});
/**
* Adds the information boards and registers every factory point. Runs after the factory and
* showroom are built.
* @param {object} context - Game context.
*/
export async function initializeFactoryPoints(context) {
context.aboutBoards = createAboutBoards(context.scene);
for (const board of context.aboutBoards.boards) addPolygon(context.collision, board.footprint);
const desk = factoryToWorld(MANAGER_POINT.u, MANAGER_POINT.v);
const sample = context.showroom.samples[PRODUCTS_SAMPLE];
context.factoryPoints = new Map([
...context.aboutBoards.boards.map((board) => [board.id, board]),
[MANAGER_POINT.id, point(MANAGER_POINT.id, desk, desk)],
[SHOWROOM_POINT_ID, point(SHOWROOM_POINT_ID, sample.position, sample.front)],
]);
context.ui.on('about-board-close', () => context.ui.close('about-board'));
}
/**
* Finds a mission target: a machine station or a factory point.
* @param {object} context - Game context.
* @param {string} id - Machine or point id.
* @returns {object | null} Station or point, or null when unknown.
*/
export function pointById(context, id) {
return context.stationById(id) ?? context.factoryPoints.get(id) ?? null;
}
/**
* The information board the walking player can read, if any.
* @param {object} context - Game context.
* @returns {object | null} Board in reach, or null.
*/
export function nearBoard(context) {
if (context.real.on || context.mode !== 'walk') return null;
return context.aboutBoards.nearest(context.player);
}
/**
* Opens the full text of a board in a sheet and counts it as read.
* @param {object} context - Game context.
* @param {object} board - Board from createAboutBoards.
*/
export function openBoard(context, board) {
context.ui.patch('about-board', { topic: board.topic });
context.keys.clear();
document.exitPointerLock?.();
context.ui.open('about-board');
context.reportMissionEvent({ type: 'read', machineId: board.id });
}
+3 -1
View File
@@ -136,7 +136,9 @@ export function openMap(context) {
mpp: 2.2, mpp: 2.2,
}); });
context.ui.open('map-dialog'); context.ui.open('map-dialog');
context.selected ??= context.objectiveStation()?.def.id ?? null; // Factory points (boards, the manager) are not on the machine map, so they are not selected.
const objective = context.objectiveStation();
context.selected ??= objective && !objective.def.poi ? objective.def.id : null;
if (context.selected) context.selectMachine(context.selected); if (context.selected) context.selectMachine(context.selected);
context.renderMachineList(); context.renderMachineList();
context.renderMap(); context.renderMap();
+19 -1
View File
@@ -1,5 +1,7 @@
import { SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js';
import { machineById } from '../../constants/locations.js'; import { machineById } from '../../constants/locations.js';
import { strings } from '../../i18n/fa.js'; import { strings } from '../../i18n/fa.js';
import { showroomModelById } from '../../utils/showroomAnswers.js';
import { fa, formatHetoman } from '../economy/gameState.js'; import { fa, formatHetoman } from '../economy/gameState.js';
import { applyMissionEvent, currentStep } from '../missions/missionProgress.js'; import { applyMissionEvent, currentStep } from '../missions/missionProgress.js';
import { ARRIVE_RADIUS, INSPECT_RADIUS, INSPECT_SECONDS } from '../missions/missions.js'; import { ARRIVE_RADIUS, INSPECT_RADIUS, INSPECT_SECONDS } from '../missions/missions.js';
@@ -62,7 +64,7 @@ function announceMission(context, mission) {
* @returns {number} Metres, or Infinity for an unknown machine. * @returns {number} Metres, or Infinity for an unknown machine.
*/ */
function distanceTo(context, machineId) { function distanceTo(context, machineId) {
const station = context.stationById(machineId); const station = context.pointById(machineId);
if (!station) return Infinity; if (!station) return Infinity;
return Math.hypot(station.front.x - context.player.x, station.front.z - context.player.z); return Math.hypot(station.front.x - context.player.x, station.front.z - context.player.z);
} }
@@ -116,3 +118,19 @@ export function orderFromCard(context) {
context.say('robot', strings.machineCard.orderThanks, 'happy'); context.say('robot', strings.machineCard.orderThanks, 'happy');
reportMissionEvent(context, { type: 'order', machineId }); reportMissionEvent(context, { type: 'order', machineId });
} }
/**
* Handles the purchase request button in the products hall: thanks the player and records the
* model as requested.
* @param {object} context - Game context.
*/
export function orderFromShowroom(context) {
const { open, modelId, ordered = [] } = context.ui.read('showroom');
const model = showroomModelById(modelId);
if (!open || !model) return;
const thanks = strings.showroom.orderThanks(model.name);
context.ui.patch('showroom', { ordered: [...new Set([...ordered, model.id])] });
context.ui.patch('showroom-response', { text: strings.showroom.robotSays(thanks) });
context.say('robot', thanks, 'happy');
reportMissionEvent(context, { type: 'request', machineId: SHOWROOM_POINT_ID });
}
+5
View File
@@ -178,6 +178,11 @@ export function updatePrompt(context) {
}); });
return; return;
} }
const board = context.nearBoard();
if (board && !context.state.busy && context.payment.phase === 'idle') {
context.ui.patch('prompt', { text: strings.prompts.board(board.def.name), hidden: false });
return;
}
let text = ''; let text = '';
const station = context.nearStation(); const station = context.nearStation();
const ride = context.mode === 'walk' ? nearestRide(context) : null; const ride = context.mode === 'walk' ? nearestRide(context) : null;
+7
View File
@@ -20,6 +20,7 @@ import {
import { awardsFor, rankFor } from '../economy/scoring.js'; import { awardsFor, rankFor } from '../economy/scoring.js';
import { strings } from '../../i18n/fa.js'; import { strings } from '../../i18n/fa.js';
import { MISSIONS } from '../missions/missions.js'; import { MISSIONS } from '../missions/missions.js';
import { SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js';
/** /**
* Runs the open showroom operation for this subsystem. * Runs the open showroom operation for this subsystem.
* @param {*} context - context. * @param {*} context - context.
@@ -41,6 +42,7 @@ export function openShowroom(context) {
context.keys.clear(); context.keys.clear();
document.exitPointerLock?.(); document.exitPointerLock?.();
context.ui.open('showroom'); context.ui.open('showroom');
context.reportMissionEvent({ type: 'view', machineId: SHOWROOM_POINT_ID });
} }
/** /**
* Runs the interact operation for this subsystem. * Runs the interact operation for this subsystem.
@@ -48,6 +50,11 @@ export function openShowroom(context) {
* @returns {*} Operation result, when applicable. * @returns {*} Operation result, when applicable.
*/ */
export function interact(context) { export function interact(context) {
const board = context.nearBoard();
if (board && !context.state.busy && context.payment.phase === 'idle') {
context.openBoard(board);
return;
}
const station = context.nearStation(); const station = context.nearStation();
if (!station || context.state.busy || context.payment.phase !== 'idle' || context.ended) return; if (!station || context.state.busy || context.payment.phase !== 'idle' || context.ended) return;
if (context.real.on && (context.real.fix?.speed ?? 0) > MAX_WALKING_SPEED) { if (context.real.on && (context.real.fix?.speed ?? 0) > MAX_WALKING_SPEED) {
+2 -8
View File
@@ -72,14 +72,8 @@ export function frame(context) {
context.last = now; context.last = now;
context.elapsed += dt; context.elapsed += dt;
const playing = context.ui.read('landing').hidden; const playing = context.ui.read('landing').hidden;
const paused = // Any open dialog (or the start screen) pauses the world.
!playing || const paused = context.anyDialogOpen() || document.hidden;
context.ui.read('shop').open ||
context.ui.read('map-dialog').open ||
context.ui.read('end').open ||
context.ui.read('showroom').open ||
context.ui.read('machine-card').open ||
document.hidden;
const steps = Math.max(1, Math.ceil(dt / (1 / 30))), const steps = Math.max(1, Math.ceil(dt / (1 / 30))),
step = dt / steps; step = dt / steps;
if (!paused) { if (!paused) {
+7
View File
@@ -35,9 +35,11 @@ import {
updateMissions, updateMissions,
openMachineCard, openMachineCard,
orderFromCard, orderFromCard,
orderFromShowroom,
} from './missionRuntime.js'; } from './missionRuntime.js';
import { resize, updateCamera, frame } from './renderLoop.js'; import { resize, updateCamera, frame } from './renderLoop.js';
import { updateTalks, greet, updateGuide, updateHints } from './worldEncounters.js'; import { updateTalks, greet, updateGuide, updateHints } from './worldEncounters.js';
import { initializeFactoryPoints, pointById, nearBoard, openBoard } from './factoryPoints.js';
import { import {
initializeTimers, initializeTimers,
initializeRenderer, initializeRenderer,
@@ -115,6 +117,10 @@ const RUNTIME_METHODS = {
updateMissions, updateMissions,
openMachineCard, openMachineCard,
orderFromCard, orderFromCard,
orderFromShowroom,
pointById,
nearBoard,
openBoard,
restartGame, restartGame,
backToMenu, backToMenu,
offerOffline, offerOffline,
@@ -142,6 +148,7 @@ export const RUNTIME_INITIALIZERS = [
initializeLightingAndWorldFiles, initializeLightingAndWorldFiles,
initializeCollisionAndRoads, initializeCollisionAndRoads,
initializeFactory, initializeFactory,
initializeFactoryPoints,
initializeStations, initializeStations,
initializeStationById, initializeStationById,
initializeClosestStation, initializeClosestStation,
+2 -1
View File
@@ -116,7 +116,8 @@ export function reset(context) {
s.model.delivery.visible = s.model.card.visible = false; s.model.delivery.visible = s.model.card.visible = false;
s.model.flap.rotation.x = 0; s.model.flap.rotation.x = 0;
} }
for (const id of ['shop', 'map-dialog', 'end', 'showroom']) context.ui.close(id); for (const id of ['shop', 'map-dialog', 'end', 'showroom', 'machine-card', 'about-board'])
context.ui.close(id);
context.keys.clear(); context.keys.clear();
context.refreshHUD(); context.refreshHUD();
} }
+14 -2
View File
@@ -26,6 +26,7 @@ import { createMapAssets } from '../world/mapRender.js';
import { formatDistance, toLocal } from '../../utils/geo.js'; import { formatDistance, toLocal } from '../../utils/geo.js';
import { strings } from '../../i18n/fa.js'; import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.js'; import { COMPANY } from '../../config/company.js';
import { SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js';
import { createObjectiveArrow } from '../models/objectiveArrow.js'; import { createObjectiveArrow } from '../models/objectiveArrow.js';
// Brand colours for materials and canvas drawings. // Brand colours for materials and canvas drawings.
@@ -438,6 +439,7 @@ export async function initializeSpawnCar(context) {
context.ui.read('end').open || context.ui.read('end').open ||
context.ui.read('showroom').open || context.ui.read('showroom').open ||
context.ui.read('machine-card').open || context.ui.read('machine-card').open ||
context.ui.read('about-board').open ||
!context.ui.read('landing').hidden; !context.ui.read('landing').hidden;
// Physical key codes, so the controls also work with a Persian keyboard layout. // Physical key codes, so the controls also work with a Persian keyboard layout.
context.keyName = (e) => context.keyName = (e) =>
@@ -705,7 +707,9 @@ export async function registerPurchaseControls(context) {
text: strings.showroom.robotSays(answer), text: strings.showroom.robotSays(answer),
}); });
context.say('robot', answer, question.topic === 'price' ? 'happy' : 'wink'); context.say('robot', answer, question.topic === 'price' ? 'happy' : 'wink');
context.reportMissionEvent({ type: 'ask', machineId: SHOWROOM_POINT_ID });
}); });
context.ui.on('showroom-order', context.orderFromShowroom);
context.ui.on('swipe', context.swipe); context.ui.on('swipe', context.swipe);
// On phones the prompt itself is the button: tap it to buy, swipe the card, or get in/out of the car. // On phones the prompt itself is the button: tap it to buy, swipe the card, or get in/out of the car.
context.ui.on('prompt', () => { context.ui.on('prompt', () => {
@@ -713,6 +717,11 @@ export async function registerPurchaseControls(context) {
context.openShowroom(); context.openShowroom();
return; return;
} }
const board = context.nearBoard();
if (board) {
context.openBoard(board);
return;
}
if (context.payment.phase === 'ready') context.swipe(); if (context.payment.phase === 'ready') context.swipe();
else if (context.nearStation()) context.interact(); else if (context.nearStation()) context.interact();
else context.toggleCar(); else context.toggleCar();
@@ -762,9 +771,9 @@ export async function registerPreferences(context) {
// ---------- Objective, route and HUD ---------- // ---------- Objective, route and HUD ----------
context.objectiveStation = () => { context.objectiveStation = () => {
if (context.waypoint) return context.stationById(context.waypoint); if (context.waypoint) return context.pointById(context.waypoint);
const target = stepTarget(context.state); const target = stepTarget(context.state);
if (target) return context.stationById(target); if (target) return context.pointById(target);
// Open-ended missions point to the nearest place not bought from yet. // Open-ended missions point to the nearest place not bought from yet.
const visited = new Set(context.state.purchases.map((p) => p.machineId)); const visited = new Set(context.state.purchases.map((p) => p.machineId));
return ( return (
@@ -1185,6 +1194,9 @@ export async function exposeDebugTools(context) {
return context.cam; return context.cam;
}, },
factory: context.factory, factory: context.factory,
get points() {
return context.factoryPoints;
},
get car() { get car() {
return context.car; return context.car;
}, },
+6 -2
View File
@@ -7,6 +7,7 @@ import {
START_MODES, START_MODES,
} from '../../constants/startModes.js'; } from '../../constants/startModes.js';
import { strings } from '../../i18n/fa.js'; import { strings } from '../../i18n/fa.js';
import { missionOrder } from '../missions/missions.js';
import { resolveCircle } from '../world/collision.js'; import { resolveCircle } from '../world/collision.js';
import { parkFamilyCar } from './garage.js'; import { parkFamilyCar } from './garage.js';
@@ -83,11 +84,13 @@ function introduce(context, mode) {
} }
/** /**
* Places the player for a mode, without touching the interface. * Sets up a fresh game for a mode, without touching the interface: where the player stands and
* which mission comes first.
* @param {object} context - Game context. * @param {object} context - Game context.
* @param {string} mode - Start mode. * @param {string} mode - Start mode.
*/ */
function placeForMode(context, mode) { function placeForMode(context, mode) {
context.state.missionOrder = missionOrder(mode);
if (mode === 'offline') placeAtPark(context); if (mode === 'offline') placeAtPark(context);
if (mode === 'products') placeAtShowroom(context); if (mode === 'products') placeAtShowroom(context);
} }
@@ -127,7 +130,8 @@ export function restartGame(context) {
*/ */
export function backToMenu(context) { export function backToMenu(context) {
context.setRealMode(false); context.setRealMode(false);
for (const id of ['shop', 'map-dialog', 'end', 'showroom', 'gps-fallback']) context.ui.close(id); const dialogs = ['shop', 'map-dialog', 'end', 'showroom', 'machine-card', 'about-board'];
for (const id of [...dialogs, 'gps-fallback']) context.ui.close(id);
document.exitPointerLock?.(); document.exitPointerLock?.();
context.reset(); context.reset();
context.ui.patch('hud', { hidden: true }); context.ui.patch('hud', { hidden: true });
+35 -13
View File
@@ -3,23 +3,43 @@ import { FACTORY } from '../../constants/locations.js';
import { isOpen } from '../economy/gameState.js'; import { isOpen } from '../economy/gameState.js';
import { strings } from '../../i18n/fa.js'; import { strings } from '../../i18n/fa.js';
/** /**
* Updates talks from the active simulation. * Distance from the player to someone who talks.
* @param {*} context - context. * @param {object} context - Game context.
* @returns {*} Operation result, when applicable. * @param {object} talk - Factory talk.
* @returns {number} Metres.
*/ */
export // People in the factory say their lines once, when the family walks up to them. function talkDistance(context, talk) {
function updateTalks(context) { const p = talk.pos();
if (context.mode !== 'walk' || context.real.on || context.lines.length > 2) return; return Math.hypot(p.x - context.player.x, p.z - context.player.z);
}
/**
* Lets people who repeat their lines (the manager) talk again once the family has walked away.
* @param {object} context - Game context.
*/
function rearmTalks(context) {
for (const t of context.factory.talks) { for (const t of context.factory.talks) {
if (t.done || (t.ready && !t.ready())) continue; if (t.repeat && t.done && talkDistance(context, t) > t.radius * 2) t.done = false;
const p = t.pos();
if (Math.hypot(p.x - context.player.x, p.z - context.player.z) > t.radius) continue;
t.done = true;
for (const text of t.lines) context.say(t.speaker, text);
return;
} }
} }
/**
* People in the factory say their lines when the family walks up to them. A talk with an `id`
* (the manager) also counts as meeting that person for the missions.
* @param {object} context - Game context.
*/
export function updateTalks(context) {
if (context.mode !== 'walk' || context.real.on || context.lines.length > 2) return;
rearmTalks(context);
const talk = context.factory.talks.find(
(t) => !t.done && (!t.ready || t.ready()) && talkDistance(context, t) <= t.radius,
);
if (!talk) return;
talk.done = true;
for (const text of talk.lines) context.say(talk.speaker, text);
if (talk.id) context.reportMissionEvent({ type: 'meet', machineId: talk.id });
}
// ---------- Robot guide ---------- // ---------- Robot guide ----------
// When the family walks up to a machine the robot pops out of it, greets them and explains what to // When the family walks up to a machine the robot pops out of it, greets them and explains what to
// do; when they leave it goes back inside. It is never a walking companion. // do; when they leave it goes back inside. It is never a walking companion.
@@ -130,7 +150,9 @@ export function updateGuide(context, dt) {
*/ */
export function updateHints(context) { export function updateHints(context) {
const target = context.objectiveStation(); const target = context.objectiveStation();
if (target && !isOpen(target.def, context.state.minutes) && !context.hints.closed) { // Only machines have opening hours; factory points are always open.
const closed = target?.def.hours && !isOpen(target.def, context.state.minutes);
if (closed && !context.hints.closed) {
context.hints.closed = true; context.hints.closed = true;
context.say( context.say(
'robot', 'robot',
+35
View File
@@ -0,0 +1,35 @@
import { FACTORY } from '../../constants/locations.js';
// The factory's local frame: u runs along the driveway towards the gate, v across it. Things placed
// in the yard outside the factory model (product samples, information boards) use it too.
/**
* Projects factory-local coordinates into world metres.
* @param {number} u - Position along the factory axis.
* @param {number} v - Position across the factory axis.
* @returns {{x: number, z: number}} World position.
*/
export function factoryToWorld(u, v) {
return {
x: FACTORY.center.x + FACTORY.axis.x * u - FACTORY.axis.z * v,
z: FACTORY.center.z + FACTORY.axis.z * u + FACTORY.axis.x * v,
};
}
/**
* Collision outline of a rectangle in the factory frame.
* @param {{u: number, v: number, halfU: number, halfV: number}} box - Centre and half sizes.
* @returns {number[]} Flat world ring [x0, z0, x1, z1, …] for the collision world.
*/
export function factoryFootprint({ u, v, halfU, halfV }) {
return [
[-halfU, -halfV],
[halfU, -halfV],
[halfU, halfV],
[-halfU, halfV],
].flatMap(([du, dv]) => Object.values(factoryToWorld(u + du, v + dv)));
}
// Rotation (Three rotation.y) that turns a model's local +X along the factory axis, so its local
// +Z faces +v.
export const FACTORY_ROTATION = Math.atan2(-FACTORY.axis.z, FACTORY.axis.x);
+36 -2
View File
@@ -172,6 +172,8 @@ export const strings = {
/** @type {(vehicle: string) => string} */ /** @type {(vehicle: string) => string} */
getIn: (vehicle) => `F · سوار ${vehicle} شو`, getIn: (vehicle) => `F · سوار ${vehicle} شو`,
getOutNearMachine: 'F · پیاده شو؛ دستگاه نزدیک است', getOutNearMachine: 'F · پیاده شو؛ دستگاه نزدیک است',
/** @type {(board: string) => string} */
board: (board) => `E · خواندن ${board}`,
}, },
vehicles: { vehicles: {
bmw: 'بی‌ام‌و', bmw: 'بی‌ام‌و',
@@ -301,6 +303,12 @@ export const strings = {
greeting: 'روبات: سلام! دربارهٔ قیمت و امکانات این مدل از من بپرس.', greeting: 'روبات: سلام! دربارهٔ قیمت و امکانات این مدل از من بپرس.',
allModels: 'روبات: سلام! قیمت و امکانات هر سه مدل را می‌توانم توضیح بدهم.', allModels: 'روبات: سلام! قیمت و امکانات هر سه مدل را می‌توانم توضیح بدهم.',
whatToKnow: 'روبات: دربارهٔ این مدل چی می‌خواهی بدانی؟', whatToKnow: 'روبات: دربارهٔ این مدل چی می‌خواهی بدانی؟',
order: 'ثبت درخواست خرید',
ordered: 'درخواست ثبت شد ✓',
contact: 'تماس با فروش',
/** @type {(model: string) => string} */
orderThanks: (model) =>
`درخواست خرید ${model} ثبت شد؛ کارشناس فروش به‌زودی با شما تماس می‌گیرد.`,
/** @type {(answer: string) => string} */ /** @type {(answer: string) => string} */
robotSays: (answer) => `روبات: ${answer}`, robotSays: (answer) => `روبات: ${answer}`,
noImage: 'نمای سه‌بعدی این مدل داخل کارخانه موجود است؛ فایل تصویر هنوز اضافه نشده.', noImage: 'نمای سه‌بعدی این مدل داخل کارخانه موجود است؛ فایل تصویر هنوز اضافه نشده.',
@@ -330,7 +338,7 @@ export const strings = {
`به ${company} خوش اومدین. بچه‌های همین دفتر نرم‌افزار دستگاه‌ها رو می‌نویسن.`, `به ${company} خوش اومدین. بچه‌های همین دفتر نرم‌افزار دستگاه‌ها رو می‌نویسن.`,
robotFirstBuy: 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.', robotFirstBuy: 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.',
robotGoToPark: robotGoToPark:
'ماشینتون جلوی همین ساختمونه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاه‌ها منتظرتونم!', 'بعدش تابلوهای حیاط رو بخونین و با آقای محمدی آشنا شین؛ بعد با ماشین برین بوستان ملت. من توی همهٔ دستگاه‌ها منتظرتونم!',
/** @type {(hours: string) => string} */ /** @type {(hours: string) => string} */
robotClosed: (hours) => `سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${hours}.`, robotClosed: (hours) => `سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${hours}.`,
/** @type {(machine: string) => string} */ /** @type {(machine: string) => string} */
@@ -369,6 +377,8 @@ export const strings = {
// Mission titles, keyed by mission id (game/missions/missions.js). // Mission titles, keyed by mission id (game/missions/missions.js).
missions: { missions: {
firstBuy: 'اولین خرید در کارخانه', firstBuy: 'اولین خرید در کارخانه',
aboutTour: `آشنایی با ${COMPANY.shortName}`,
productsTour: 'نمایشگاه محصولات',
parkGate: 'دستگاه ورودی بوستان ملت', parkGate: 'دستگاه ورودی بوستان ملت',
parkLake: 'بازدید از دستگاه کنار دریاچه', parkLake: 'بازدید از دستگاه کنار دریاچه',
parkSports: 'سفارش دستگاه برای زمین ورزشی', parkSports: 'سفارش دستگاه برای زمین ورزشی',
@@ -389,8 +399,29 @@ export const strings = {
buy: (place) => `از ${place} خرید کن`, buy: (place) => `از ${place} خرید کن`,
/** @type {(done: string, count: string) => string} */ /** @type {(done: string, count: string) => string} */
places: (done, count) => `از ${count} مکان شهر خرید کن (${done}/${count})`, places: (done, count) => `از ${count} مکان شهر خرید کن (${done}/${count})`,
/** @type {(board: string) => string} */
read: (board) => `${board} را بخوان`,
/** @type {(person: string) => string} */
meet: (person) => `در دفتر مدیریت با ${person} آشنا شو`,
view: () => 'به نمایشگاه دستگاه‌ها برو و مدل‌ها را ببین',
ask: () => 'قیمت یا امکانات یک مدل را از روبات بپرس',
request: () => 'درخواست خرید یک دستگاه را ثبت کن',
allDone: 'همهٔ مأموریت‌ها انجام شد', allDone: 'همهٔ مأموریت‌ها انجام شد',
}, },
// Names of the factory places missions point at (constants/factoryPoints.js).
points: {
'board-about': 'تابلوی دربارهٔ ما',
'board-contact': 'تابلوی تماس با ما',
'board-address': 'تابلوی آدرس کارخانه',
mohammadi: 'آقای محمدی',
showroom: 'نمایشگاه دستگاه‌ها',
},
// Information boards in the factory yard.
boards: {
titles: { about: 'دربارهٔ ما', contact: 'تماس با ما', address: 'آدرس کارخانه' },
readMore: 'برای خواندن کامل، نزدیک شو و E را بزن',
bullet: '•',
},
machineCard: { machineCard: {
title: 'مشخصات دستگاه', title: 'مشخصات دستگاه',
installedAt: 'محل نصب', installedAt: 'محل نصب',
@@ -502,10 +533,13 @@ export const strings = {
label: 'واحد حسابداری', label: 'واحد حسابداری',
lines: ['سلام! اینجا فروش همهٔ دستگاه‌ها ثبت می‌شه. هر خریدی بکنین، فاکتورش این‌جا میاد!'], lines: ['سلام! اینجا فروش همهٔ دستگاه‌ها ثبت می‌شه. هر خریدی بکنین، فاکتورش این‌جا میاد!'],
}, },
// Mr Mohammadi's welcome; he says it each time the family comes to his desk.
managerLines: [ managerLines: [
`سلام، خوش اومدین! من محمدی‌ام، مدیر ${COMPANY.shortName}.`, `سلام، خوش اومدین! من محمدی‌ام، مدیر ${COMPANY.shortName}.`,
COMPANY.about.history,
`چیزهایی که برامون مهمه: ${COMPANY.about.values.join('، ')}.`,
'امروز چهل نفر توی سالن تولید کار می‌کنن: خم‌کاری، برش، رنگ، یخچال‌سازی و مونتاژ.', 'امروز چهل نفر توی سالن تولید کار می‌کنن: خم‌کاری، برش، رنگ، یخچال‌سازی و مونتاژ.',
'یه سر به دفتر فنی بزنین و از دستگاهش خرید کنین؛ بعد برین سراغ دستگاه‌های شهر!', 'مدل‌های دستگاه رو توی نمایشگاه حیاط ببینین؛ روبات اونجا قیمت‌ها رو می‌گه.',
], ],
truck: 'بابا ببین! کامیون داره دستگاه‌های بسته‌بندی‌شده رو بار می‌زنه که ببره شهر!', truck: 'بابا ببین! کامیون داره دستگاه‌های بسته‌بندی‌شده رو بار می‌زنه که ببره شهر!',
// One comment per production area, spoken the first time the family walks in. // One comment per production area, spoken the first time the family walks in.
+19
View File
@@ -0,0 +1,19 @@
import { InfoSheet } from './startScreen/InfoSheet.jsx';
/** @typedef {{nodes: object, actions: object}} AboutBoardSheetProps */
/**
* Full text of the information board the player is reading in the factory yard. Reuses the start
* screen's company sections, so boards and the landing page always say the same thing.
* @param {AboutBoardSheetProps} props - Interface snapshots and actions.
* @returns {import('react').ReactElement} Sheet.
*/
export function AboutBoardSheet({ nodes, actions }) {
const board = nodes['about-board'] ?? {};
return (
<InfoSheet
id="about-board"
section={board.open ? board.topic : null}
onClose={() => actions.emit('about-board-close')}
/>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { COMPANY } from '@/config/company.js';
import { strings } from '@/i18n/fa.js';
import { AboutBoardSheet } from './AboutBoardSheet.jsx';
afterEach(cleanup);
// jsdom has no modal dialogs; open and close them by toggling the attribute.
beforeAll(() => {
HTMLDialogElement.prototype.showModal = function showModal() {
this.setAttribute('open', '');
};
HTMLDialogElement.prototype.close = function close() {
this.removeAttribute('open');
};
});
describe('AboutBoardSheet', () => {
it('shows the board topic in full and reports closing', () => {
const actions = { emit: vi.fn() };
const nodes = { 'about-board': { open: true, topic: 'contact' } };
render(<AboutBoardSheet nodes={nodes} actions={actions} />);
expect(screen.getByRole('heading', { name: strings.landing.sections.contact })).toBeVisible();
expect(screen.getByText(COMPANY.contact.email)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: strings.landing.close }));
expect(actions.emit).toHaveBeenCalledWith('about-board-close');
});
it('renders nothing inside while closed', () => {
render(<AboutBoardSheet nodes={{}} actions={{ emit: vi.fn() }} />);
expect(screen.queryByText(COMPANY.contact.email)).not.toBeInTheDocument();
});
});
+2
View File
@@ -8,6 +8,7 @@ import { UpdateNotice } from './UpdateNotice.jsx';
import { EndDialog } from './EndDialog.jsx'; import { EndDialog } from './EndDialog.jsx';
import { GpsFallbackDialog } from './GpsFallbackDialog.jsx'; import { GpsFallbackDialog } from './GpsFallbackDialog.jsx';
import { MachineCard } from './MachineCard.jsx'; import { MachineCard } from './MachineCard.jsx';
import { AboutBoardSheet } from './AboutBoardSheet.jsx';
/** Composes independently managed game dialogs. */ /** Composes independently managed game dialogs. */
export function GameDialogs(props) { export function GameDialogs(props) {
return ( return (
@@ -22,6 +23,7 @@ export function GameDialogs(props) {
<EndDialog {...props} /> <EndDialog {...props} />
<GpsFallbackDialog {...props} /> <GpsFallbackDialog {...props} />
<MachineCard {...props} /> <MachineCard {...props} />
<AboutBoardSheet {...props} />
</> </>
); );
} }
+15
View File
@@ -1,6 +1,7 @@
import { GameNode } from '@/components/shared/GameNode.jsx'; import { GameNode } from '@/components/shared/GameNode.jsx';
import { SHOWROOM_CATALOG } from '@/constants/showroomCatalog.js'; import { SHOWROOM_CATALOG } from '@/constants/showroomCatalog.js';
import { robotFace } from '@/config/assets.js'; import { robotFace } from '@/config/assets.js';
import { COMPANY } from '@/config/company.js';
import { showroomModelById, formatRial } from '@/utils/showroomAnswers.js'; import { showroomModelById, formatRial } from '@/utils/showroomAnswers.js';
import { ShowroomImage } from './ShowroomImage.jsx'; import { ShowroomImage } from './ShowroomImage.jsx';
import { strings } from '@/i18n/fa.js'; import { strings } from '@/i18n/fa.js';
@@ -13,6 +14,7 @@ import { strings } from '@/i18n/fa.js';
export function ShowroomDialog({ nodes, actions }) { export function ShowroomDialog({ nodes, actions }) {
const model = showroomModelById(nodes.showroom?.modelId); const model = showroomModelById(nodes.showroom?.modelId);
if (!model) return null; if (!model) return null;
const isOrdered = nodes.showroom.ordered?.includes(model.id) ?? false;
return ( return (
<GameNode <GameNode
as="dialog" as="dialog"
@@ -87,6 +89,19 @@ export function ShowroomDialog({ nodes, actions }) {
{strings.showroom.askFeatures} {strings.showroom.askFeatures}
</button> </button>
</div> </div>
<div className="mt-3 flex flex-wrap gap-3">
<button
type="button"
className="start-option start-option-primary"
disabled={isOrdered}
onClick={() => actions.emit('showroom-order')}
>
{isOrdered ? strings.showroom.ordered : strings.showroom.order}
</button>
<a className="start-chip" href={`tel:${COMPANY.contact.phone}`}>
{strings.showroom.contact}
</a>
</div>
<div className="mt-4 flex items-start gap-3 border-s-2 border-gold ps-4"> <div className="mt-4 flex items-start gap-3 border-s-2 border-gold ps-4">
<img <img
className="h-16 w-16 shrink-0 object-contain" className="h-16 w-16 shrink-0 object-contain"
+22
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { strings } from '@/i18n/fa.js';
import { ShowroomDialog } from './ShowroomDialog.jsx'; import { ShowroomDialog } from './ShowroomDialog.jsx';
afterEach(cleanup); afterEach(cleanup);
describe('showroom robot questions', () => { describe('showroom robot questions', () => {
@@ -17,4 +18,25 @@ describe('showroom robot questions', () => {
fireEvent.click(screen.getByRole('button', { name: '۶۰ کانال', hidden: true })); fireEvent.click(screen.getByRole('button', { name: '۶۰ کانال', hidden: true }));
expect(actions.emit).toHaveBeenCalledWith('showroom-select', 'rm-60'); expect(actions.emit).toHaveBeenCalledWith('showroom-select', 'rm-60');
}); });
it('sends a purchase request for the selected model once', () => {
const actions = { emit: vi.fn(), close: vi.fn() };
const { rerender } = render(
<ShowroomDialog nodes={{ showroom: { modelId: 'rm-35' } }} actions={actions} />,
);
fireEvent.click(screen.getByRole('button', { name: strings.showroom.order, hidden: true }));
expect(actions.emit).toHaveBeenCalledWith('showroom-order');
rerender(
<ShowroomDialog
nodes={{ showroom: { modelId: 'rm-35', ordered: ['rm-35'] } }}
actions={actions}
/>,
);
expect(
screen.getByRole('button', { name: strings.showroom.ordered, hidden: true }),
).toBeDisabled();
expect(
screen.getByRole('link', { name: strings.showroom.contact, hidden: true }),
).toHaveAttribute('href', expect.stringMatching(/^tel:/));
});
}); });
+5 -4
View File
@@ -2,18 +2,19 @@ import { Sheet } from '@/components/shared/Sheet.jsx';
import { strings } from '@/i18n/fa.js'; import { strings } from '@/i18n/fa.js';
import { SECTION_BODIES } from './InfoSectionContent.jsx'; import { SECTION_BODIES } from './InfoSectionContent.jsx';
/** @typedef {{section: string | null, onClose: function}} InfoSheetProps */ /** @typedef {{section: string | null, onClose: function, id?: string}} InfoSheetProps */
/** /**
* Shows one company information section in a sheet. * Shows one company information section in a sheet.
* @param {InfoSheetProps} props - Open section key, or null when closed, and the close callback. * @param {InfoSheetProps} props - Open section key, or null when closed, the close callback and
* an optional element id (for a second sheet on the same page).
* @returns {import('react').ReactElement} Sheet. * @returns {import('react').ReactElement} Sheet.
*/ */
export function InfoSheet({ section, onClose }) { export function InfoSheet({ section, onClose, id = 'info-sheet' }) {
const Body = SECTION_BODIES[section]; const Body = SECTION_BODIES[section];
const title = section === 'about' ? strings.landing.history : strings.landing.sections[section]; const title = section === 'about' ? strings.landing.history : strings.landing.sections[section];
return ( return (
<Sheet <Sheet
id="info-sheet" id={id}
open={Boolean(section)} open={Boolean(section)}
title={title ?? ''} title={title ?? ''}
closeLabel={strings.landing.close} closeLabel={strings.landing.close}
+54 -6
View File
@@ -2,7 +2,10 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createState, purchase } from '../src/game/economy/gameState.js'; import { createState, purchase } from '../src/game/economy/gameState.js';
import { machineById, machines } from '../src/constants/locations.js'; import { machineById, machines } from '../src/constants/locations.js';
import { MISSIONS } from '../src/game/missions/missions.js'; import { MISSIONS, missionOrder } from '../src/game/missions/missions.js';
import { FACTORY_POINT_IDS } from '../src/constants/factoryPoints.js';
import { boardLines } from '../src/game/models/aboutBoards.js';
import { COMPANY } from '../src/config/company.js';
import { import {
applyMissionEvent, applyMissionEvent,
currentMission, currentMission,
@@ -24,18 +27,24 @@ function buyAt(state, id) {
return applyMissionEvent(state, { type: 'buy', machineId: id }); return applyMissionEvent(state, { type: 'buy', machineId: id });
} }
test('Every mission step points at a real machine and has text', () => { test('Every mission step points at a real machine or factory place and has text', () => {
for (const mission of MISSIONS) { for (const mission of MISSIONS) {
assert.ok(strings.missions[mission.id], `title for ${mission.id}`); assert.ok(strings.missions[mission.id], `title for ${mission.id}`);
for (const step of mission.steps) { for (const step of mission.steps) {
assert.ok(strings.steps[step.type], `text for ${step.type}`); assert.ok(strings.steps[step.type], `text for ${step.type}`);
if (step.target) assert.ok(machineById(step.target), `machine ${step.target}`); if (!step.target) continue;
const known = machineById(step.target) || FACTORY_POINT_IDS.includes(step.target);
assert.ok(known, `target ${step.target}`);
} }
} }
const parkSites = machines.filter((m) => m.category === 'park').map((m) => m.id); const parkSites = machines.filter((m) => m.category === 'park').map((m) => m.id);
assert.deepEqual(parkSites, ['mellat-park', 'mellat-lake', 'mellat-sports']); assert.deepEqual(parkSites, ['mellat-park', 'mellat-lake', 'mellat-sports']);
const parkMissions = MISSIONS.filter((m) => m.steps.some((s) => parkSites.includes(s.target))); const parkMissions = MISSIONS.filter((m) => m.steps.some((s) => parkSites.includes(s.target)));
assert.ok(parkMissions.length > MISSIONS.length / 2, 'most missions are about park machines'); // The About Us and products tours stay in the factory; out in the city, the park leads.
const cityMissions = MISSIONS.filter((m) =>
m.steps.every((s) => !FACTORY_POINT_IDS.includes(s.target)),
);
assert.ok(parkMissions.length > cityMissions.length / 2, 'most city missions are in the park');
}); });
test('The first mission is: go to the factory, then buy there', () => { test('The first mission is: go to the factory, then buy there', () => {
@@ -54,12 +63,12 @@ test('The first mission is: go to the factory, then buy there', () => {
assert.equal(state.xp, xp + result.mission.reward.xp); assert.equal(state.xp, xp + result.mission.reward.xp);
assert.ok(state.balance > balance, 'reward paid after the purchase'); assert.ok(state.balance > balance, 'reward paid after the purchase');
assert.deepEqual(state.missionsDone, ['firstBuy']); assert.deepEqual(state.missionsDone, ['firstBuy']);
assert.equal(stepTarget(state), 'mellat-park'); assert.equal(stepTarget(state), 'board-about', 'then the About Us tour, still in the factory');
}); });
test('Park missions chain locate, goto, inspect, order and buy steps', () => { test('Park missions chain locate, goto, inspect, order and buy steps', () => {
const state = createState(); const state = createState();
state.missionIndex = 1; state.missionIndex = state.missionOrder.indexOf('parkGate');
const steps = [ const steps = [
['locate', 'mellat-park'], ['locate', 'mellat-park'],
['arrive', 'mellat-park'], ['arrive', 'mellat-park'],
@@ -95,3 +104,42 @@ test('The explorer mission counts different city machines and shows progress', (
const items = missionListItems(state); const items = missionListItems(state);
assert.ok(items.every((item) => !item.active && item.steps.length === 0)); assert.ok(items.every((item) => !item.active && item.steps.length === 0));
}); });
test('Each start mode leads with its own mission and keeps all the others', () => {
assert.equal(missionOrder('offline')[0], 'firstBuy');
assert.equal(missionOrder('real')[0], 'firstBuy');
assert.equal(missionOrder('about')[0], 'aboutTour');
assert.equal(missionOrder('products')[0], 'productsTour');
const all = MISSIONS.map((m) => m.id).sort();
for (const mode of ['real', 'offline', 'about', 'products', undefined])
assert.deepEqual([...missionOrder(mode)].sort(), all);
});
test('The About Us tour: read the boards and meet Mr Mohammadi', () => {
const state = createState();
state.missionOrder = missionOrder('about');
assert.equal(currentStepText(state), strings.steps.read(strings.points['board-about']));
const read = (id) => applyMissionEvent(state, { type: 'read', machineId: id });
assert.equal(read('board-contact').stepDone, false, 'boards are read in order');
assert.equal(read('board-about').stepDone, true);
assert.equal(stepTarget(state), 'mohammadi');
assert.equal(applyMissionEvent(state, { type: 'meet', machineId: 'mohammadi' }).stepDone, true);
assert.equal(read('board-contact').mission.id, 'aboutTour');
assert.equal(currentMission(state).id, 'firstBuy', 'the rest follow in list order');
assert.equal(missionListItems(state)[0].title, strings.missions.aboutTour);
});
test('The products tour: open the hall, ask the robot, send a purchase request', () => {
const state = createState();
state.missionOrder = missionOrder('products');
for (const type of ['view', 'ask']) applyMissionEvent(state, { type, machineId: 'showroom' });
const result = applyMissionEvent(state, { type: 'request', machineId: 'showroom' });
assert.equal(result.mission.id, 'productsTour');
});
test('Boards show the company details from the config', () => {
assert.equal(boardLines('about')[0], COMPANY.about.history);
assert.equal(boardLines('about').length, 1 + COMPANY.about.values.length);
assert.ok(boardLines('contact').some((line) => line.includes(COMPANY.contact.phone)));
assert.ok(boardLines('address')[0].includes(COMPANY.contact.address));
});