feat: add landing-style start screen with four start modes

Phase 3 of turning the game into the company landing page.

- Replace the Iran-map briefing with a brand start screen over the
  orbiting 3D factory: logo, tagline and four ways in.
- Real world: safety notice, then GPS; if location is denied or
  missing, a friendly dialog offers offline play instead.
- Offline: start at Park Mellat beside the family car with the route
  to the factory, where the first purchase mission is.
- About Us: start in the factory yard. Products: start at the machine
  samples with the showroom open.
- Contact, address with map links, FAQ, careers, gallery and social
  links open in a sheet (bottom sheet on phones) from company config.
- Restart replays the chosen mode. Layout scrolls on short screens and
  respects safe areas.
- Add tests for the start screen, map links and theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 08:39:22 +03:30
co-authored by Claude Opus 5.5
parent 611960511e
commit d9ae8a45da
28 changed files with 838 additions and 261 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ import { publishProgress } from '../store/slices/gameSlice.js';
*/
export function createInterfaceBridge(store) {
let nodes = {
briefing: {
landing: {
hidden: false,
},
hud: {
+13
View File
@@ -120,6 +120,19 @@ export function resetGarage(context) {
setActive(context, garage.vehicles[0]);
}
/**
* Moves the family car (the first fleet vehicle) to a new spot, for modes that start away from
* the factory. The next reset parks it back in the yard.
* @param {object} context - Game context.
* @param {{x: number, z: number, heading: number}} pose - Where to park it.
*/
export function parkFamilyCar(context, pose) {
const family = context.garage.vehicles[0];
family.state = createCar(pose.x, pose.z, pose.heading);
placeModel(family);
setActive(context, family);
}
/**
* Takes over the manager's car once he has parked it, so it can be driven like the others.
* @param {object} context - Game context.
+9 -2
View File
@@ -26,6 +26,7 @@ export function setRealMode(context, on) {
error: null,
waiting: null,
stop: null,
offeredOffline: false,
});
for (const s of context.stations) if (s.def.test) s.model.group.visible = on;
context.ui.classState('gps-toggle', 'on', on);
@@ -55,7 +56,11 @@ export function setRealMode(context, on) {
context.real.error = null;
},
onHeading: (heading) => (context.real.heading = heading),
onError: (message) => (context.real.error = message),
onError: (message) => {
context.real.error = message;
// Started from the start screen's real-world option: offer offline play instead.
if (context.startMode === 'real') context.offerOffline(message);
},
onStatus: (message) => (context.real.waiting = message),
stabilizer: (context.real.stabilizer = createStabilizer()),
});
@@ -114,8 +119,10 @@ function askRealMode(context, thenStart) {
context.ui.on('real-go', async () => {
context.ui.close('real-intro');
await requestCompass();
// Record the mode first: without any GPS the error arrives during setRealMode itself.
if (thenStart) context.startMode = 'real';
context.setRealMode(true);
if (thenStart) context.startMission();
if (thenStart) context.startGame('real');
});
}
/**
+2 -22
View File
@@ -1,7 +1,5 @@
import * as THREE from 'three';
import iran from '../../data/iran.json';
import { clearFraction } from '../world/collision.js';
import { drawIran } from '../world/mapRender.js';
import { syncActiveVehicle, trafficObstacles, updateGarage } from './garage.js';
/**
* Runs the resize operation for this subsystem.
@@ -73,7 +71,7 @@ export function frame(context) {
const dt = THREE.MathUtils.clamp((now - context.last) / 1000, 0, context.MAX_FRAME);
context.last = now;
context.elapsed += dt;
const playing = context.ui.read('briefing').hidden;
const playing = context.ui.read('landing').hidden;
const paused =
!playing ||
context.ui.read('shop').open ||
@@ -83,25 +81,6 @@ export function frame(context) {
document.hidden;
const steps = Math.max(1, Math.ceil(dt / (1 / 30))),
step = dt / steps;
if (!playing) {
// Briefing: animate Iran, type the orders, slowly orbit the start point behind the overlay.
const ic = context.$('#iran'),
dpr = context.sizeCanvas(ic),
c = ic.getContext('2d');
c.setTransform(dpr, 0, 0, dpr, 0, 0);
drawIran(c, iran, {
width: ic.width / dpr,
height: ic.height / dpr,
t: context.elapsed,
cities: context.CITIES,
});
context.briefChars = Math.min(context.BRIEF.length, context.briefChars + dt * 55);
context.ui.patch('brief-text', {
text:
context.BRIEF.slice(0, Math.floor(context.briefChars)) +
(context.briefChars < context.BRIEF.length ? '▌' : ''),
});
}
if (!paused) {
for (let i = 0; i < steps; i++) {
context.state.minutes += (step * 10) / 60;
@@ -173,6 +152,7 @@ export function frame(context) {
}
context.updateCamera(dt);
} else {
// Start screen: a slow orbit around the factory yard behind the menu.
const a = context.elapsed * 0.08;
context.camera.position.set(
context.player.x + Math.cos(a) * 30,
+6 -2
View File
@@ -1,4 +1,5 @@
import { placeStation, updateReachCircles, reset, startMission } from './runtimeLifecycle.js';
import { placeStation, updateReachCircles, reset } from './runtimeLifecycle.js';
import { startGame, restartGame, offerOffline, switchToOffline } from './startModes.js';
import { look, toggleCar, updateWalk, updateDrive } from './movement.js';
import { say, updateSubtitles, tone } from './audioAndDialogue.js';
import {
@@ -105,7 +106,10 @@ const RUNTIME_METHODS = {
renderMachineList,
selectMachine,
openMap,
startMission,
startGame,
restartGame,
offerOffline,
switchToOffline,
setRealMode,
setArMode,
askRealMode,
-24
View File
@@ -3,7 +3,6 @@ import { createState } from '../economy/gameState.js';
import { createPayment } from '../economy/payment.js';
import { createVendingMachine } from '../models/vending.js';
import { resetGarage } from './garage.js';
import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
@@ -121,26 +120,3 @@ export function reset(context) {
context.keys.clear();
context.refreshHUD();
}
// ---------- Input ----------
/**
* Runs the start mission operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function startMission(context) {
context.ui.patch('briefing', {
hidden: true,
});
context.ui.patch('hud', {
hidden: false,
});
const { dialogue } = strings;
context.say('robot', dialogue.showroomIntro, 'wink');
context.say('child', dialogue.childAsksFactory);
context.say('father', dialogue.fatherAnswersFactory);
context.say('child', dialogue.childAsksArrival);
context.say('father', dialogue.fatherAnswersArrival);
if (!context.isTouch && !context.real.on)
context.canvas.requestPointerLock?.()?.catch?.(() => {});
}
+6 -6
View File
@@ -436,7 +436,7 @@ export async function initializeSpawnCar(context) {
context.ui.read('map-dialog').open ||
context.ui.read('end').open ||
context.ui.read('showroom').open ||
!context.ui.read('briefing').hidden;
!context.ui.read('landing').hidden;
// Physical key codes, so the controls also work with a Persian keyboard layout.
context.keyName = (e) =>
e.code.startsWith('Key') ? e.code.slice(3).toLowerCase() : e.key.toLowerCase();
@@ -450,7 +450,7 @@ export async function registerKeyboard(context) {
context.listen(window, 'keydown', (e) => {
if (e.target instanceof HTMLInputElement) return;
const k = context.keyName(e);
if (k === 'm' && !e.repeat && context.ui.read('briefing').hidden) {
if (k === 'm' && !e.repeat && context.ui.read('landing').hidden) {
e.preventDefault();
context.ui.read('map-dialog').open ? context.ui.close('map-dialog') : context.openMap();
return;
@@ -735,7 +735,7 @@ export async function registerPreferences(context) {
});
context.ui.on('again', context.reset);
context.ui.on('continue', () => context.ui.close('end'));
context.ui.on('restart', context.reset);
context.ui.on('restart', context.restartGame);
// ---------- Objective, route and HUD ----------
context.objectiveStation = () => {
@@ -1050,9 +1050,9 @@ export async function initializeCities(context) {
* @returns {*} Operation result, when applicable.
*/
export async function initializeBriefingAndTracking(context) {
context.BRIEF = strings.briefing.lines.join('\n');
context.briefChars = 0;
context.ui.on('start-mission', context.startMission);
context.ui.on('start', (mode) => context.startGame(mode));
context.ui.on('gps-offline', context.switchToOffline);
context.ui.on('gps-fallback-close', () => context.ui.close('gps-fallback'));
// ---------- Real-world (GPS) mode ----------
// The family mirrors the phone's position on the real map of Mashhad. Walking to a real place where
+145
View File
@@ -0,0 +1,145 @@
import { CITY_START } from '../../constants/locations.js';
import {
DEFAULT_START_MODE,
PARK_CAR_OFFSET,
PARK_PLAYER_OFFSET,
PRODUCTS_SAMPLE,
START_MODES,
} from '../../constants/startModes.js';
import { strings } from '../../i18n/fa.js';
import { resolveCircle } from '../world/collision.js';
import { parkFamilyCar } from './garage.js';
// Starts the game in one of the start-screen modes (see constants/startModes.js). Every mode
// begins from a fresh reset, which puts the family in the factory yard; modes that start
// elsewhere move them afterwards.
/**
* Points the player (and the chase camera behind them) along a heading.
* @param {object} context - Game context.
* @param {{x: number, z: number}} at - Position to stand on.
* @param {number} heading - Heading in radians (Three rotation.y).
*/
function standAt(context, at, heading) {
const spot = resolveCircle(context.collision, at.x, at.z, 1);
Object.assign(context.player, { x: spot.x, z: spot.z, heading });
// The camera sits at player + (sin yaw, cos yaw), so this puts it behind the player.
context.cam.yaw = Math.atan2(-Math.cos(heading), Math.sin(heading));
}
/**
* Moves the family and their car to the Park Mellat gate, facing the machine, and routes to the
* factory for the first mission.
* @param {object} context - Game context.
*/
function placeAtPark(context) {
const machine = context.stationById(CITY_START);
const local = (offset) => context.rotateLocal(offset.x, offset.z, machine.rot);
const player = local(PARK_PLAYER_OFFSET),
car = local(PARK_CAR_OFFSET);
const towardsMachine = Math.atan2(player.z, -player.x);
standAt(context, { x: machine.x + player.x, z: machine.z + player.z }, towardsMachine);
const carSpot = resolveCircle(context.collision, machine.x + car.x, machine.z + car.z, 2);
parkFamilyCar(context, { x: carSpot.x, z: carSpot.z, heading: machine.rot + Math.PI / 2 });
context.waypoint = 'factory';
context.route = null;
}
/**
* Puts the player in front of the middle showroom sample.
* @param {object} context - Game context.
*/
function placeAtShowroom(context) {
const sample = context.showroom.samples[PRODUCTS_SAMPLE];
const heading = Math.atan2(
-(sample.position.z - sample.front.z),
sample.position.x - sample.front.x,
);
standAt(context, sample.front, heading);
}
/**
* Says the opening lines for a mode.
* @param {object} context - Game context.
* @param {string} mode - Start mode.
*/
function introduce(context, mode) {
const { dialogue } = strings;
if (mode === 'offline') {
context.say('father', dialogue.offlineStart);
context.say('child', dialogue.offlineFollow);
return;
}
if (mode === 'real') {
context.say('father', dialogue.realStart);
return;
}
if (mode === 'products') return;
context.say('robot', dialogue.showroomIntro, 'wink');
context.say('child', dialogue.childAsksFactory);
context.say('father', dialogue.fatherAnswersFactory);
context.say('child', dialogue.childAsksArrival);
context.say('father', dialogue.fatherAnswersArrival);
}
/**
* Places the player for a mode, without touching the interface.
* @param {object} context - Game context.
* @param {string} mode - Start mode.
*/
function placeForMode(context, mode) {
if (mode === 'offline') placeAtPark(context);
if (mode === 'products') placeAtShowroom(context);
}
/**
* Leaves the start screen and starts playing in a mode.
* @param {object} context - Game context.
* @param {string} mode - One of START_MODES; unknown values start the About Us tour.
*/
export function startGame(context, mode) {
context.startMode = START_MODES.includes(mode) ? mode : DEFAULT_START_MODE;
context.reset();
placeForMode(context, context.startMode);
context.ui.patch('landing', { hidden: true });
context.ui.patch('hud', { hidden: false });
introduce(context, context.startMode);
if (context.startMode === 'products') {
context.openShowroom();
return;
}
if (!context.isTouch && !context.real.on)
context.canvas.requestPointerLock?.()?.catch?.(() => {});
}
/**
* Restarts in the mode the player chose on the start screen.
* @param {object} context - Game context.
*/
export function restartGame(context) {
context.reset();
placeForMode(context, context.startMode ?? DEFAULT_START_MODE);
}
/**
* Explains that location is unavailable and offers to play offline instead. Shown once per
* real-world session, the first time tracking reports an error.
* @param {object} context - Game context.
* @param {string} message - Why location failed.
*/
export function offerOffline(context, message) {
if (context.real.offeredOffline) return;
context.real.offeredOffline = true;
context.ui.patch('gps-fallback-text', { text: message });
context.ui.open('gps-fallback');
}
/**
* Switches from a failed real-world session to offline play at Park Mellat.
* @param {object} context - Game context.
*/
export function switchToOffline(context) {
context.ui.close('gps-fallback');
context.setRealMode(false);
context.startGame('offline');
}