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:
@@ -7,7 +7,7 @@ import { publishProgress } from '../store/slices/gameSlice.js';
|
||||
*/
|
||||
export function createInterfaceBridge(store) {
|
||||
let nodes = {
|
||||
briefing: {
|
||||
landing: {
|
||||
hidden: false,
|
||||
},
|
||||
hud: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?.(() => {});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user