diff --git a/src/main.ts b/src/main.ts index 23b4ec6..945eeda 100644 --- a/src/main.ts +++ b/src/main.ts @@ -913,6 +913,8 @@ async function boot() { // anywhere the player needs to be — the bases' own beacons take over. const heading = quests.active && quests.active.stage !== 'return' ? quests.active : null; markers.setObjective(heading ? nodeOf(heading.targetNode) : null); + // The minimap has always drawn field contacts; now the world does too. + markers.setOpportunity(opportunities.current); }, render(alpha, frameDt) { @@ -1024,7 +1026,19 @@ async function boot() { view, physics, // Live state, so a script can find something interesting and go look at it. - state: { units, combat, heat, areas, intel, quests, front, model, bases, pursuit }, + state: { + units, + combat, + heat, + areas, + intel, + quests, + front, + model, + bases, + pursuit, + opportunities, + }, condition: () => condition, /** * Force the car's condition, for looking at how a wrecked car drives diff --git a/src/render/markers.ts b/src/render/markers.ts index 4109fee..b56c3e0 100644 --- a/src/render/markers.ts +++ b/src/render/markers.ts @@ -1,11 +1,22 @@ import * as THREE from 'three'; import type { Base } from '../sim/bases'; +import { OPPORTUNITY_RADIUS, type Opportunity } from '../sim/opportunities'; /** * Bases and the active objective, as things you can see from a distance. * The player navigates by looking, so both need to be visible over scenery. */ const BEACON_HEIGHT = 34; +/** + * How tall a field contact's marker stands. + * + * Deliberately a fraction of a base beacon. These are meant to be work you + * *find*, not work you plan — you cannot compare one against anything or route + * to it, you take the detour in front of you or you drive on. A pillar visible + * across the map would turn them into errands. At this height it clears a car + * and reads a street away, and no further. + */ +const CONTACT_HEIGHT = 7; /** * A slim pillar plus a ring on the ground. @@ -15,7 +26,7 @@ const BEACON_HEIGHT = 34; * middle of one, that is exactly where you spend your time. Narrow enough to see * past, tall enough to see over buildings. */ -function beacon(colour: number, radius: number): THREE.Group { +function beacon(colour: number, radius: number, height = BEACON_HEIGHT): THREE.Group { const group = new THREE.Group(); const material = new THREE.MeshBasicMaterial({ color: colour, @@ -28,10 +39,10 @@ function beacon(colour: number, radius: number): THREE.Group { }); const pillar = new THREE.Mesh( - new THREE.CylinderGeometry(0.9, 0.9, BEACON_HEIGHT, 8, 1, true), + new THREE.CylinderGeometry(0.9, 0.9, height, 8, 1, true), material, ); - pillar.position.y = BEACON_HEIGHT / 2; + pillar.position.y = height / 2; group.add(pillar); // The ring is what tells you where to actually stop. @@ -68,7 +79,54 @@ export function createMarkers(scene: THREE.Scene, bases: Base[]) { objective.visible = false; scene.add(objective); + /* + * A field contact, as something actually standing in the road. + * + * The minimap has always drawn these as a pink dot and the world had nothing + * there at all, so the one kind of work you are supposed to find by looking + * was the one kind you could only find by reading the map — and driving to + * the dot put you in an empty street. + * + * So there is a marker, and underneath it the thing itself: somebody standing + * at the roadside, or a burnt-out car worth stripping. The prop is what you + * actually recognise; the marker is so you can tell it from the scenery at + * the speed you are going. + */ + const contact = beacon(0xcf6bd6, OPPORTUNITY_RADIUS, CONTACT_HEIGHT); + const person = new THREE.Mesh( + new THREE.CapsuleGeometry(0.45, 1.4, 4, 8), + new THREE.MeshStandardMaterial({ color: 0xd8c9a4, roughness: 0.8 }), + ); + person.position.y = 1.05; + person.castShadow = true; + contact.add(person); + + const wreck = new THREE.Mesh( + new THREE.BoxGeometry(1.9, 1.3, 4.2), + new THREE.MeshStandardMaterial({ color: 0x2b2521, roughness: 1 }), + ); + wreck.position.y = 0.6; + // Slewed and nose-down, so it reads as abandoned rather than parked. + wreck.rotation.set(0.06, 0.7, 0.11); + wreck.castShadow = true; + contact.add(wreck); + + contact.visible = false; + scene.add(contact); + return { + /** + * Show whoever is waiting, or nobody. A wreck is a wreck; everything else + * is a person standing where a person would not normally stand. + */ + setOpportunity(current: Opportunity | null) { + contact.visible = current !== null; + if (!current) return; + contact.position.set(current.x, 0, current.z); + wreck.visible = current.kind === 'wreck'; + person.visible = current.kind !== 'wreck'; + }, + /** Point the objective beacon at a target, or hide it when idle. */ setObjective(target: { x: number; z: number } | null) { objective.visible = target !== null; @@ -77,6 +135,7 @@ export function createMarkers(scene: THREE.Scene, bases: Base[]) { /** Slow spin, so a beacon reads as a marker rather than scenery. */ update(elapsed: number) { objective.rotation.y = elapsed * 0.6; + contact.rotation.y = elapsed * 0.45; }, }; }