Field contacts exist in the world, not only on the map

The minimap has always drawn these as a pink dot and the 3D world had
nothing there at all, so the one kind of work you are meant 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.

There is now a marker in the world, and underneath it the thing itself:
somebody standing where nobody stands, or a burnt-out car worth
stripping. The prop is what you recognise; the marker is so you can
pick it out from scenery at the speed you are going, and the ring on
the ground is the radius you have to stop inside.

Deliberately a fifth the height of a base beacon. These are work you
find rather than work you plan - you cannot compare one against
anything or route to it - and a pillar visible across the map would
quietly turn them into errands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-09 15:39:07 +02:00
parent 627eca2f3d
commit 2ffef66ef8
2 changed files with 77 additions and 4 deletions

View File

@ -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

View File

@ -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;
},
};
}