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:
@@ -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;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:/));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user