openvibers/openvibe.games Commits
Scraplandia's first steps get a waypoint: an arrow and a distance toward the nearest pile, oak, deposit or scrap with something left, else a landmark zone (The Forest, The Quarry, The Scrapyard, editable in the map), and the recovery bird for the haul (#124)
Changes
@@@ -58,6 +58,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).### Added++- **A waypoint for the first steps.** An arrow and a distance at the top of the screen point toward the current++ step's target: "Oak Tree · 30 m". The target is the nearest node in sight that has something left (a starter pile,++ preferring whichever gives what you still lack; an oak; a stone deposit; a scrap pile), else a landmark. The haul step++ points at the recovery bird's announced landing. "The forest is west of town" was all a new scrapper had to find a++ forest over 100 m away.++ - Landmarks are map zones with the wilds' own rules, so they change no gameplay: The Forest, The Quarry and The++ Scrapyard on Scrap Island. They can be moved in the editor.- **The town's props look like things.** Merchant stalls have a counter, posts, a striped awning in the stall's colourand goods on top. Crates have edge boards, barrels have hoops and a bung, and the storage box has a lid and a latch.The workbench has legs, a shelf, a vise and a saw, the still has a dome and a pipe, planters grow, and the campfire@@@ -18,7 +18,7 @@ import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'import type { Appearance } from '@openvibe/protocol'import { Color3 } from '@babylonjs/core/Maths/math.color.js'import { Scene } from '@babylonjs/core/scene.js'--import { WATER_LEVEL, terrainHeight } from '@openvibe/content'++import { WATER_LEVEL, getMapOverride, terrainHeight } from '@openvibe/content'import { DEFAULT_MOVEMENT } from '@openvibe/gameplay'import { Sfx, footstepSurface, materialForVisual } from './audio/sfx.js'import { HitChips } from './render/hitChips.js'@@@ -63,6 +63,7 @@ import { ConstraintLineRenderer } from './render/constraintLines.js'import { PlacementGhost } from './game/placementGhost.js'import { WeaponSettings, weaponModuleFor } from './weapons/registry.js'import { FirstSteps } from './ui/firstSteps.js'++import { Waypoint, pickTarget, type NodeSighting } from './ui/waypoint.js'/*** Client bootstrap: engine/scene -> character customization (live preview)@@@ -239,6 +240,7 @@ async function start(): Promise<void> {...(sso ? { bearer: sso } : {}),})// Scraplandia's first-steps checklist (not in Salvage Rush or a creator world, whose worlds are something else).++ let updateWaypoint: (() => void) | null = nullif (!worldId && content.world.id === 'scrap_island') {const firstSteps = new FirstSteps(uiRoot, () => ({count: (item) => state.countOf(item),@@@ -247,6 +249,39 @@ async function start(): Promise<void> {state.events.on('inventory', () => firstSteps.update())state.events.on('skills', () => firstSteps.update())state.events.on('announce', (text) => firstSteps.onAnnounce(String(text)))++ // The waypoint toward the current step's target (refreshed from the frame loop).++ const waypoint = new Waypoint(uiRoot)++ updateWaypoint = () => {++ const step = firstSteps.current()++ const from = { x: player.move.pos.x, z: player.move.pos.z }++ if (!step) return waypoint.update(from, player.viewYaw, null)++ if (step.id === 'haul') {++ const bird = firstSteps.birdAt++ return waypoint.update(++ from,++ player.viewYaw,++ bird ? { label: 'Recovery bird', x: bird[0], z: bird[1] } : null,++ )++ }++ if (!step.toward) return waypoint.update(from, player.viewYaw, null)++ // Gathering: point at whichever pile gives what is still missing.++ const hasWood = state.countOf('wood_log') > 0 || state.countOf('wood_plank') > 0++ const hasStone = state.countOf('stone') > 0++ const prefer = step.id === 'gather' ? (hasStone ? ['branch_pile'] : hasWood ? ['loose_stones'] : []) : []++ const sightings: NodeSighting[] = []++ for (const e of state.entities.values()) {++ if (e.kind !== 'resource' || !e.def) continue++ sightings.push({++ def: e.def,++ name: content.nodeType(e.def)?.name ?? e.def,++ x: e.pos[0],++ z: e.pos[2],++ remaining: e.remaining ?? 1,++ })++ }++ const zones = getMapOverride(content.world)?.zones ?? []++ waypoint.update(from, player.viewYaw, pickTarget(from, { ...step.toward, prefer }, sightings, zones))++ }}const roundUi =content.world.id === 'salvage_rush_arena'@@@ -388,6 +423,7 @@ async function start(): Promise<void> {if (id === state.myEntityId && (kind === 'hurt' || kind === 'death')) sfx.hurt()})let stride = 0++ let lastWaypoint = 0let wasGrounded = truelet fallSpeed = 0@@@ -652,6 +688,10 @@ async function start(): Promise<void> {}wasGrounded = groundedsfx.update(elapsed, environment.daylight)++ if (updateWaypoint && now - lastWaypoint > 150) {++ lastWaypoint = now++ updateWaypoint()++ }chips.update(elapsed)// Beams (modular: any weapon whose module declares firesBeam gets this@@@ -86,3 +86,35 @@ describe('first steps', () => {expect(progress(view, { done: ['gather'] })).toEqual(['gather', 'mine'])})})++++describe('the waypoint hooks', () => {++ it('names the current step, and none once the panel is closed', () => {++ const steps = new FirstSteps(root, read, storage as unknown as Storage)++ expect(steps.current()?.id).toBe('gather')++ items = { wood_log: 1, stone: 2 }++ steps.update()++ expect(steps.current()?.id).toBe('axe')++ ;(root.querySelector('[data-first-steps-close]') as HTMLElement).click()++ expect(steps.current()).toBeNull()++ })++++ it('remembers where the recovery bird said it would land, until it extracts or moves on', () => {++ const steps = new FirstSteps(root, read, storage as unknown as Storage)++ steps.onAnnounce(++ '🚁 Recovery bird inbound near (56, -20) — hold the circle to secure your haul!',++ )++ expect(steps.birdAt).toEqual([56, -20])++ steps.onAnnounce('🚁 The recovery bird is circling. Get inside and HOLD.')++ expect(steps.birdAt).toEqual([56, -20])++ steps.onAnnounce('🚁 The recovery bird moves on.')++ expect(steps.birdAt).toBeNull()++ })++++ it('gives the outdoor steps somewhere to point', () => {++ const toward = Object.fromEntries(FIRST_STEPS.map((s) => [s.id, s.toward]))++ expect(toward.gather?.nodes).toEqual(['branch_pile', 'loose_stones'])++ expect(toward.chop).toEqual({ nodes: ['oak_tree'], zone: 'forest' })++ expect(toward.mine?.zone).toBe('quarry')++ expect(toward.scrap?.zone).toBe('scrapyard')++ })++})@@@ -22,6 +22,11 @@ export interface FirstStep {title: stringhint: stringdone: (v: StepView) => boolean++ /**++ * Where the waypoint points (ui/waypoint.ts): the nearest node of these types that has something left, else the++ * map zone with this id (a landmark). `items` narrows the nodes to the ones yielding what the player still lacks.++ */++ toward?: { nodes?: string[]; zone?: string }}export const FIRST_STEPS: FirstStep[] = [@@@ -32,6 +37,7 @@ export const FIRST_STEPS: FirstStep[] = [done: (v) =>(v.count('wood_log') > 0 || v.count('wood_plank') > 0 || v.count('stone_axe') > 0) &&(v.count('stone') > 0 || v.count('stone_axe') > 0),++ toward: { nodes: ['branch_pile', 'loose_stones'] },},{id: 'axe',@@@ -44,18 +50,21 @@ export const FIRST_STEPS: FirstStep[] = [title: 'Chop an oak',hint: 'The forest is west of town. Hold the axe and swing (left click).',done: (v) => v.level('woodcutting') >= 2,++ toward: { nodes: ['oak_tree'], zone: 'forest' },},{id: 'mine',title: 'Mine at the quarry',hint: 'South-west of town. A Stone Pickaxe breaks the stone deposits.',done: (v) => v.level('mining') >= 2,++ toward: { nodes: ['stone_deposit'], zone: 'quarry' },},{id: 'scrap',title: 'Salvage the scrapyard',hint: 'East of town, past the gate. Rustjaw thugs hang around out there.',done: (v) => v.count('scrap_metal') >= 4 || v.level('scavenging') >= 2,++ toward: { nodes: ['scrap_pile'], zone: 'scrapyard' },},{id: 'haul',@@@ -126,8 +135,21 @@ export class FirstSteps {this.render()}++ /** The step being worked on, or null when all are done or the panel was closed. */++ current(): FirstStep | null {++ if (this.saved.closed) return null++ const done = new Set(this.saved.done)++ return FIRST_STEPS.find((s) => !done.has(s.id)) ?? null++ }++++ /** Where the recovery bird said it would land ("… near (56, -20) …"), for the haul step's waypoint. */++ birdAt: [number, number] | null = null++/** A server announce: an extraction that secured something completes the last step. */onAnnounce(text: string): void {++ const near = /near \((-?\d+(?:\.\d+)?), (-?\d+(?:\.\d+)?)\)/.exec(text)++ if (near) this.birdAt = [Number(near[1]), Number(near[2])]++ if (/Extracted|recovery bird moves on/i.test(text)) this.birdAt = nullif (/Extracted! [1-9]/.test(text)) {this.saved.hauled = truethis.update()@@@ -737,6 +737,41 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {color: #f0b040;}++/* The first-steps waypoint: an arrow and a distance at the top centre, toward the current step's target. */++.waypoint {++ position: absolute;++ top: 12px;++ left: 50%;++ transform: translateX(-50%);++ display: inline-flex;++ align-items: center;++ gap: 8px;++ padding: 5px 12px 5px 8px;++ border-radius: 999px;++ border: 1px solid rgba(240, 176, 64, 0.45);++ background: rgba(14, 18, 24, 0.78);++ color: #f3e6c8;++ font: 600 13px/1.2 system-ui, sans-serif;++ pointer-events: none;++ white-space: nowrap;++}++.waypoint[hidden] {++ display: none;++}++.waypoint-arrow {++ display: inline-flex;++ align-items: center;++ justify-content: center;++ width: 22px;++ height: 22px;++ border-radius: 50%;++ background: #f0b040;++ color: #1a1206;++ font-size: 15px;++ font-weight: 800;++ transition: transform 0.15s linear;++}++/* Settings: the sound volume slider, tall enough to drag with a thumb. */.volume-range {display: block;nnew file mode 100644@@@ -0,0 +1,73 @@++// @vitest-environment jsdom++import { describe, expect, it } from 'vitest'++import { Waypoint, bearing, pickTarget, type NodeSighting } from './waypoint.js'++++const node = (def: string, x: number, z: number, remaining = 3): NodeSighting => ({++ def,++ name: def,++ x,++ z,++ remaining,++})++const zones = [{ id: 'forest', name: 'The Forest', min: [-160, -10, -30], max: [-50, 80, 80] }]++++describe('pickTarget', () => {++ it('points at the nearest node of the wanted kind that has something left', () => {++ const t = pickTarget(++ { x: 0, z: 0 },++ { nodes: ['oak_tree'], zone: 'forest' },++ [++ node('oak_tree', 30, 0, 0),++ node('oak_tree', -40, 0),++ node('oak_tree', -20, 5),++ node('loose_stones', 2, 0),++ ],++ zones,++ )++ expect(t).toEqual({ label: 'oak_tree', x: -20, z: 5 })++ })++++ it('falls back to the landmark zone when no such node is in sight', () => {++ expect(pickTarget({ x: 0, z: 0 }, { nodes: ['oak_tree'], zone: 'forest' }, [], zones)).toEqual({++ label: 'The Forest',++ x: -105,++ z: 25,++ })++ expect(pickTarget({ x: 0, z: 0 }, { nodes: ['oak_tree'] }, [], zones)).toBeNull()++ })++++ it('prefers the pile that gives what is still missing, even if another is nearer', () => {++ const t = pickTarget(++ { x: 0, z: 30 },++ { nodes: ['branch_pile', 'loose_stones'], prefer: ['branch_pile'] },++ [node('loose_stones', 6, 34), node('branch_pile', -6, 34)],++ zones,++ )++ expect(t?.label).toBe('branch_pile')++ })++})++++describe('bearing', () => {++ it('is 0 straight ahead, a quarter turn clockwise to the right, and wraps', () => {++ expect(bearing({ x: 0, z: 0 }, 0, { x: 0, z: 10 })).toBeCloseTo(0)++ expect(bearing({ x: 0, z: 0 }, 0, { x: 10, z: 0 })).toBeCloseTo(Math.PI / 2)++ expect(bearing({ x: 0, z: 0 }, Math.PI / 2, { x: 10, z: 0 })).toBeCloseTo(0)++ expect(Math.abs(bearing({ x: 0, z: 0 }, 0.1, { x: 0, z: -10 }))).toBeCloseTo(Math.PI - 0.1)++ })++})++++describe('Waypoint', () => {++ it('shows the target and its distance, and hides when there is none or you are there', () => {++ document.body.innerHTML = '<div id="ui"></div>'++ const root = document.getElementById('ui')!++ const w = new Waypoint(root)++ const el = root.querySelector('.waypoint') as HTMLElement++ w.update({ x: 0, z: 0 }, 0, { label: 'The Forest', x: -100, z: 0 })++ expect(el.hidden).toBe(false)++ expect(el.textContent).toContain('The Forest · 100 m')++ w.update({ x: -98, z: 0 }, 0, { label: 'The Forest', x: -100, z: 0 })++ expect(el.hidden).toBe(true)++ w.update({ x: 0, z: 0 }, 0, null)++ expect(el.hidden).toBe(true)++ })++})nnew file mode 100644@@@ -0,0 +1,108 @@++/**++ * The first-steps waypoint: an arrow and a distance at the top of the screen toward the current step's target, so++ * "the forest is west of town" becomes something to follow. Scrap Island's forest is over 100 m from the spawn, and a++ * new scrapper had no marker to find it by.++ */++++export interface WaypointTarget {++ label: string++ x: number++ z: number++}++++export interface NodeSighting {++ def: string++ name: string++ x: number++ z: number++ remaining: number++}++++export interface ZoneBox {++ id: string++ name: string++ min: readonly number[]++ max: readonly number[]++}++++/**++ * Where to point: the nearest sighted node of the wanted types that has something left (of `nodes`, preferring the++ * ones in `prefer` when any are sighted), else the named zone's centre. Null when there is nothing to point at.++ */++export function pickTarget(++ from: { x: number; z: number },++ toward: { nodes?: readonly string[]; zone?: string; prefer?: readonly string[] },++ nodes: Iterable<NodeSighting>,++ zones: readonly ZoneBox[],++): WaypointTarget | null {++ const wanted = new Set(toward.nodes ?? [])++ const preferred = new Set(toward.prefer ?? [])++ let best: NodeSighting | null = null++ let bestScore = Infinity++ for (const n of nodes) {++ if (!wanted.has(n.def) || n.remaining <= 0) continue++ // A preferred type wins over any other; within a rank the nearest wins.++ const score =++ Math.hypot(n.x - from.x, n.z - from.z) + (preferred.size && !preferred.has(n.def) ? 1e6 : 0)++ if (score < bestScore) {++ best = n++ bestScore = score++ }++ }++ if (best) return { label: best.name, x: best.x, z: best.z }++ const zone = toward.zone ? zones.find((z) => z.id === toward.zone) : undefined++ if (zone)++ return {++ label: zone.name,++ x: (zone.min[0]! + zone.max[0]!) / 2,++ z: (zone.min[2]! + zone.max[2]!) / 2,++ }++ return null++}++++/** The arrow's turn, radians clockwise from straight ahead, for a target seen from `from` facing `yaw` (0 = +z). */++export function bearing(++ from: { x: number; z: number },++ yaw: number,++ to: { x: number; z: number },++): number {++ const a = Math.atan2(to.x - from.x, to.z - from.z) - yaw++ return Math.atan2(Math.sin(a), Math.cos(a))++}++++export class Waypoint {++ private readonly el: HTMLElement++ private readonly arrow: HTMLElement++ private readonly text: HTMLElement++ private shown = ''++++ constructor(root: HTMLElement) {++ this.el = document.createElement('div')++ this.el.className = 'waypoint'++ this.el.hidden = true++ this.el.setAttribute('role', 'status')++ this.arrow = document.createElement('span')++ this.arrow.className = 'waypoint-arrow'++ this.arrow.setAttribute('aria-hidden', 'true')++ this.arrow.textContent = '↑'++ this.text = document.createElement('span')++ this.el.append(this.arrow, this.text)++ root.appendChild(this.el)++ }++++ /** Point at `target` from the player's position and facing; null hides it. Within 4 m it hides too (you're there). */++ update(from: { x: number; z: number }, yaw: number, target: WaypointTarget | null): void {++ const d = target ? Math.hypot(target.x - from.x, target.z - from.z) : 0++ if (!target || d < 4) {++ this.el.hidden = true++ return++ }++ this.el.hidden = false++ this.arrow.style.transform = `rotate(${bearing(from, yaw, target).toFixed(3)}rad)`++ const label = `${target.label} · ${Math.round(d)} m`++ if (label !== this.shown) {++ this.text.textContent = label++ this.shown = label++ }++ }++}@@@ -91,6 +91,23 @@ export const ISLAND = {spawn: [0, 0, -17] as Vec3,}++/** Named areas for the first-steps waypoint (zone id, name, [x, z] min, [x, z] max). */++const LANDMARKS: [string, string, [number, number], [number, number]][] = [++ [++ 'forest',++ 'The Forest',++ [ISLAND.forest.center[0] - ISLAND.forest.radius, ISLAND.forest.center[1] - ISLAND.forest.radius],++ [ISLAND.forest.center[0] + ISLAND.forest.radius, ISLAND.forest.center[1] + ISLAND.forest.radius],++ ],++ [++ 'quarry',++ 'The Quarry',++ [ISLAND.quarry.center[0] - 22, ISLAND.quarry.center[1] - 22],++ [ISLAND.quarry.center[0] + 22, ISLAND.quarry.center[1] + 22],++ ],++ ['scrapyard', 'The Scrapyard', [...ISLAND.scrapyard.min], [...ISLAND.scrapyard.max]],++]++/** Dirt roads from the city gates out to each district (x, z polylines). */const ROADS: [number, number][][] = [[@@@ -1031,6 +1048,15 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {max: [W + 2, 60, W + 2],rules: { pvp: false, build: false, physgun: true },},++ // Landmarks: named areas with the wilds' own rules, so they change nothing; the first-steps waypoint points at++ // them until a tree, deposit or scrap pile is in sight, and the editor can move them with the map.++ ...LANDMARKS.map(([id, name, [x0, z0], [x1, z1]]) => ({++ id,++ name,++ min: [x0, -10, z0] as Vec3,++ max: [x1, 80, z1] as Vec3,++ rules: { pvp: true, build: true, physgun: true },++ })),],models: [],textures: [],nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/client/src/ui/waypoint.test.ts'++// covers: 'apps/client/src/ui'++// Generated by node test/run.js --index; do not edit.
@@ -58,6 +58,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
@@ -58,6 +58,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
### Added
+- **A waypoint for the first steps.** An arrow and a distance at the top of the screen point toward the current
+ step's target: "Oak Tree · 30 m". The target is the nearest node in sight that has something left (a starter pile,
+ preferring whichever gives what you still lack; an oak; a stone deposit; a scrap pile), else a landmark. The haul step
+ points at the recovery bird's announced landing. "The forest is west of town" was all a new scrapper had to find a
+ forest over 100 m away.
+ - Landmarks are map zones with the wilds' own rules, so they change no gameplay: The Forest, The Quarry and The
+ Scrapyard on Scrap Island. They can be moved in the editor.
- **The town's props look like things.** Merchant stalls have a counter, posts, a striped awning in the stall's colour
- **The town's props look like things.** Merchant stalls have a counter, posts, a striped awning in the stall's colour
and goods on top. Crates have edge boards, barrels have hoops and a bung, and the storage box has a lid and a latch.
and goods on top. Crates have edge boards, barrels have hoops and a bung, and the storage box has a lid and a latch.
The workbench has legs, a shelf, a vise and a saw, the still has a dome and a pipe, planters grow, and the campfire
The workbench has legs, a shelf, a vise and a saw, the still has a dome and a pipe, planters grow, and the campfire
@@ -18,7 +18,7 @@ import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
@@ -18,7 +18,7 @@ import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
import type { Appearance } from '@openvibe/protocol'
import type { Appearance } from '@openvibe/protocol'
import { Color3 } from '@babylonjs/core/Maths/math.color.js'
import { Color3 } from '@babylonjs/core/Maths/math.color.js'
import { Scene } from '@babylonjs/core/scene.js'
import { Scene } from '@babylonjs/core/scene.js'
-import { WATER_LEVEL, terrainHeight } from '@openvibe/content'
+import { WATER_LEVEL, getMapOverride, terrainHeight } from '@openvibe/content'
import { DEFAULT_MOVEMENT } from '@openvibe/gameplay'
import { DEFAULT_MOVEMENT } from '@openvibe/gameplay'
import { Sfx, footstepSurface, materialForVisual } from './audio/sfx.js'
import { Sfx, footstepSurface, materialForVisual } from './audio/sfx.js'
import { HitChips } from './render/hitChips.js'
import { HitChips } from './render/hitChips.js'
@@ -63,6 +63,7 @@ import { ConstraintLineRenderer } from './render/constraintLines.js'
@@ -63,6 +63,7 @@ import { ConstraintLineRenderer } from './render/constraintLines.js'
import { PlacementGhost } from './game/placementGhost.js'
import { PlacementGhost } from './game/placementGhost.js'
import { WeaponSettings, weaponModuleFor } from './weapons/registry.js'
import { WeaponSettings, weaponModuleFor } from './weapons/registry.js'
import { FirstSteps } from './ui/firstSteps.js'
import { FirstSteps } from './ui/firstSteps.js'
+import { Waypoint, pickTarget, type NodeSighting } from './ui/waypoint.js'
/**
/**
* Client bootstrap: engine/scene -> character customization (live preview)
* Client bootstrap: engine/scene -> character customization (live preview)
@@ -239,6 +240,7 @@ async function start(): Promise<void> {
@@ -239,6 +240,7 @@ async function start(): Promise<void> {
...(sso ? { bearer: sso } : {}),
...(sso ? { bearer: sso } : {}),
})
})
// Scraplandia's first-steps checklist (not in Salvage Rush or a creator world, whose worlds are something else).
// Scraplandia's first-steps checklist (not in Salvage Rush or a creator world, whose worlds are something else).
+ let updateWaypoint: (() => void) | null = null
if (!worldId && content.world.id === 'scrap_island') {
if (!worldId && content.world.id === 'scrap_island') {
const firstSteps = new FirstSteps(uiRoot, () => ({
const firstSteps = new FirstSteps(uiRoot, () => ({
count: (item) => state.countOf(item),
count: (item) => state.countOf(item),
@@ -247,6 +249,39 @@ async function start(): Promise<void> {
@@ -247,6 +249,39 @@ async function start(): Promise<void> {
state.events.on('inventory', () => firstSteps.update())
state.events.on('inventory', () => firstSteps.update())
state.events.on('skills', () => firstSteps.update())
state.events.on('skills', () => firstSteps.update())
state.events.on('announce', (text) => firstSteps.onAnnounce(String(text)))
state.events.on('announce', (text) => firstSteps.onAnnounce(String(text)))
+ // The waypoint toward the current step's target (refreshed from the frame loop).
+ const waypoint = new Waypoint(uiRoot)
+ updateWaypoint = () => {
+ const step = firstSteps.current()
+ const from = { x: player.move.pos.x, z: player.move.pos.z }
+ if (!step) return waypoint.update(from, player.viewYaw, null)
+ if (step.id === 'haul') {
+ const bird = firstSteps.birdAt
+ return waypoint.update(
+ from,
+ player.viewYaw,
+ bird ? { label: 'Recovery bird', x: bird[0], z: bird[1] } : null,
+ )
+ }
+ if (!step.toward) return waypoint.update(from, player.viewYaw, null)
+ // Gathering: point at whichever pile gives what is still missing.
+ const hasWood = state.countOf('wood_log') > 0 || state.countOf('wood_plank') > 0
+ const hasStone = state.countOf('stone') > 0
+ const prefer = step.id === 'gather' ? (hasStone ? ['branch_pile'] : hasWood ? ['loose_stones'] : []) : []
+ const sightings: NodeSighting[] = []
+ for (const e of state.entities.values()) {
+ if (e.kind !== 'resource' || !e.def) continue
+ sightings.push({
+ def: e.def,
+ name: content.nodeType(e.def)?.name ?? e.def,
+ x: e.pos[0],
+ z: e.pos[2],
+ remaining: e.remaining ?? 1,
+ })
+ }
+ const zones = getMapOverride(content.world)?.zones ?? []
+ waypoint.update(from, player.viewYaw, pickTarget(from, { ...step.toward, prefer }, sightings, zones))
+ }
}
}
const roundUi =
const roundUi =
content.world.id === 'salvage_rush_arena'
content.world.id === 'salvage_rush_arena'
@@ -388,6 +423,7 @@ async function start(): Promise<void> {
@@ -388,6 +423,7 @@ async function start(): Promise<void> {
if (id === state.myEntityId && (kind === 'hurt' || kind === 'death')) sfx.hurt()
if (id === state.myEntityId && (kind === 'hurt' || kind === 'death')) sfx.hurt()
})
})
let stride = 0
let stride = 0
+ let lastWaypoint = 0
let wasGrounded = true
let wasGrounded = true
let fallSpeed = 0
let fallSpeed = 0
@@ -652,6 +688,10 @@ async function start(): Promise<void> {
@@ -652,6 +688,10 @@ async function start(): Promise<void> {
}
}
wasGrounded = grounded
wasGrounded = grounded
sfx.update(elapsed, environment.daylight)
sfx.update(elapsed, environment.daylight)
+ if (updateWaypoint && now - lastWaypoint > 150) {
+ lastWaypoint = now
+ updateWaypoint()
+ }
chips.update(elapsed)
chips.update(elapsed)
// Beams (modular: any weapon whose module declares firesBeam gets this
// Beams (modular: any weapon whose module declares firesBeam gets this
@@ -86,3 +86,35 @@ describe('first steps', () => {
@@ -86,3 +86,35 @@ describe('first steps', () => {
expect(progress(view, { done: ['gather'] })).toEqual(['gather', 'mine'])
expect(progress(view, { done: ['gather'] })).toEqual(['gather', 'mine'])
})
})
})
})
+
+describe('the waypoint hooks', () => {
+ it('names the current step, and none once the panel is closed', () => {
+ const steps = new FirstSteps(root, read, storage as unknown as Storage)
+ expect(steps.current()?.id).toBe('gather')
+ items = { wood_log: 1, stone: 2 }
+ steps.update()
+ expect(steps.current()?.id).toBe('axe')
+ ;(root.querySelector('[data-first-steps-close]') as HTMLElement).click()
+ expect(steps.current()).toBeNull()
+ })
+
+ it('remembers where the recovery bird said it would land, until it extracts or moves on', () => {
+ const steps = new FirstSteps(root, read, storage as unknown as Storage)
+ steps.onAnnounce(
+ '🚁 Recovery bird inbound near (56, -20) — hold the circle to secure your haul!',
+ )
+ expect(steps.birdAt).toEqual([56, -20])
+ steps.onAnnounce('🚁 The recovery bird is circling. Get inside and HOLD.')
+ expect(steps.birdAt).toEqual([56, -20])
+ steps.onAnnounce('🚁 The recovery bird moves on.')
+ expect(steps.birdAt).toBeNull()
+ })
+
+ it('gives the outdoor steps somewhere to point', () => {
+ const toward = Object.fromEntries(FIRST_STEPS.map((s) => [s.id, s.toward]))
+ expect(toward.gather?.nodes).toEqual(['branch_pile', 'loose_stones'])
+ expect(toward.chop).toEqual({ nodes: ['oak_tree'], zone: 'forest' })
+ expect(toward.mine?.zone).toBe('quarry')
+ expect(toward.scrap?.zone).toBe('scrapyard')
+ })
+})
@@ -22,6 +22,11 @@ export interface FirstStep {
@@ -22,6 +22,11 @@ export interface FirstStep {
title: string
title: string
hint: string
hint: string
done: (v: StepView) => boolean
done: (v: StepView) => boolean
+ /**
+ * Where the waypoint points (ui/waypoint.ts): the nearest node of these types that has something left, else the
+ * map zone with this id (a landmark). `items` narrows the nodes to the ones yielding what the player still lacks.
+ */
+ toward?: { nodes?: string[]; zone?: string }
}
}
export const FIRST_STEPS: FirstStep[] = [
export const FIRST_STEPS: FirstStep[] = [
@@ -32,6 +37,7 @@ export const FIRST_STEPS: FirstStep[] = [
@@ -32,6 +37,7 @@ export const FIRST_STEPS: FirstStep[] = [
done: (v) =>
done: (v) =>
(v.count('wood_log') > 0 || v.count('wood_plank') > 0 || v.count('stone_axe') > 0) &&
(v.count('wood_log') > 0 || v.count('wood_plank') > 0 || v.count('stone_axe') > 0) &&
(v.count('stone') > 0 || v.count('stone_axe') > 0),
(v.count('stone') > 0 || v.count('stone_axe') > 0),
+ toward: { nodes: ['branch_pile', 'loose_stones'] },
},
},
{
{
id: 'axe',
id: 'axe',
@@ -44,18 +50,21 @@ export const FIRST_STEPS: FirstStep[] = [
@@ -44,18 +50,21 @@ export const FIRST_STEPS: FirstStep[] = [
title: 'Chop an oak',
title: 'Chop an oak',
hint: 'The forest is west of town. Hold the axe and swing (left click).',
hint: 'The forest is west of town. Hold the axe and swing (left click).',
done: (v) => v.level('woodcutting') >= 2,
done: (v) => v.level('woodcutting') >= 2,
+ toward: { nodes: ['oak_tree'], zone: 'forest' },
},
},
{
{
id: 'mine',
id: 'mine',
title: 'Mine at the quarry',
title: 'Mine at the quarry',
hint: 'South-west of town. A Stone Pickaxe breaks the stone deposits.',
hint: 'South-west of town. A Stone Pickaxe breaks the stone deposits.',
done: (v) => v.level('mining') >= 2,
done: (v) => v.level('mining') >= 2,
+ toward: { nodes: ['stone_deposit'], zone: 'quarry' },
},
},
{
{
id: 'scrap',
id: 'scrap',
title: 'Salvage the scrapyard',
title: 'Salvage the scrapyard',
hint: 'East of town, past the gate. Rustjaw thugs hang around out there.',
hint: 'East of town, past the gate. Rustjaw thugs hang around out there.',
done: (v) => v.count('scrap_metal') >= 4 || v.level('scavenging') >= 2,
done: (v) => v.count('scrap_metal') >= 4 || v.level('scavenging') >= 2,
+ toward: { nodes: ['scrap_pile'], zone: 'scrapyard' },
},
},
{
{
id: 'haul',
id: 'haul',
@@ -126,8 +135,21 @@ export class FirstSteps {
@@ -126,8 +135,21 @@ export class FirstSteps {
this.render()
this.render()
}
}
+ /** The step being worked on, or null when all are done or the panel was closed. */
+ current(): FirstStep | null {
+ if (this.saved.closed) return null
+ const done = new Set(this.saved.done)
+ return FIRST_STEPS.find((s) => !done.has(s.id)) ?? null
+ }
+
+ /** Where the recovery bird said it would land ("… near (56, -20) …"), for the haul step's waypoint. */
+ birdAt: [number, number] | null = null
+
/** A server announce: an extraction that secured something completes the last step. */
/** A server announce: an extraction that secured something completes the last step. */
onAnnounce(text: string): void {
onAnnounce(text: string): void {
+ const near = /near \((-?\d+(?:\.\d+)?), (-?\d+(?:\.\d+)?)\)/.exec(text)
+ if (near) this.birdAt = [Number(near[1]), Number(near[2])]
+ if (/Extracted|recovery bird moves on/i.test(text)) this.birdAt = null
if (/Extracted! [1-9]/.test(text)) {
if (/Extracted! [1-9]/.test(text)) {
this.saved.hauled = true
this.saved.hauled = true
this.update()
this.update()
@@ -737,6 +737,41 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -737,6 +737,41 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
color: #f0b040;
color: #f0b040;
}
}
+/* The first-steps waypoint: an arrow and a distance at the top centre, toward the current step's target. */
+.waypoint {
+ position: absolute;
+ top: 12px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 12px 5px 8px;
+ border-radius: 999px;
+ border: 1px solid rgba(240, 176, 64, 0.45);
+ background: rgba(14, 18, 24, 0.78);
+ color: #f3e6c8;
+ font: 600 13px/1.2 system-ui, sans-serif;
+ pointer-events: none;
+ white-space: nowrap;
+}
+.waypoint[hidden] {
+ display: none;
+}
+.waypoint-arrow {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background: #f0b040;
+ color: #1a1206;
+ font-size: 15px;
+ font-weight: 800;
+ transition: transform 0.15s linear;
+}
+
/* Settings: the sound volume slider, tall enough to drag with a thumb. */
/* Settings: the sound volume slider, tall enough to drag with a thumb. */
.volume-range {
.volume-range {
display: block;
display: block;
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest'
+import { Waypoint, bearing, pickTarget, type NodeSighting } from './waypoint.js'
+
+const node = (def: string, x: number, z: number, remaining = 3): NodeSighting => ({
+ def,
+ name: def,
+ x,
+ z,
+ remaining,
+})
+const zones = [{ id: 'forest', name: 'The Forest', min: [-160, -10, -30], max: [-50, 80, 80] }]
+
+describe('pickTarget', () => {
+ it('points at the nearest node of the wanted kind that has something left', () => {
+ const t = pickTarget(
+ { x: 0, z: 0 },
+ { nodes: ['oak_tree'], zone: 'forest' },
+ [
+ node('oak_tree', 30, 0, 0),
+ node('oak_tree', -40, 0),
+ node('oak_tree', -20, 5),
+ node('loose_stones', 2, 0),
+ ],
+ zones,
+ )
+ expect(t).toEqual({ label: 'oak_tree', x: -20, z: 5 })
+ })
+
+ it('falls back to the landmark zone when no such node is in sight', () => {
+ expect(pickTarget({ x: 0, z: 0 }, { nodes: ['oak_tree'], zone: 'forest' }, [], zones)).toEqual({
+ label: 'The Forest',
+ x: -105,
+ z: 25,
+ })
+ expect(pickTarget({ x: 0, z: 0 }, { nodes: ['oak_tree'] }, [], zones)).toBeNull()
+ })
+
+ it('prefers the pile that gives what is still missing, even if another is nearer', () => {
+ const t = pickTarget(
+ { x: 0, z: 30 },
+ { nodes: ['branch_pile', 'loose_stones'], prefer: ['branch_pile'] },
+ [node('loose_stones', 6, 34), node('branch_pile', -6, 34)],
+ zones,
+ )
+ expect(t?.label).toBe('branch_pile')
+ })
+})
+
+describe('bearing', () => {
+ it('is 0 straight ahead, a quarter turn clockwise to the right, and wraps', () => {
+ expect(bearing({ x: 0, z: 0 }, 0, { x: 0, z: 10 })).toBeCloseTo(0)
+ expect(bearing({ x: 0, z: 0 }, 0, { x: 10, z: 0 })).toBeCloseTo(Math.PI / 2)
+ expect(bearing({ x: 0, z: 0 }, Math.PI / 2, { x: 10, z: 0 })).toBeCloseTo(0)
+ expect(Math.abs(bearing({ x: 0, z: 0 }, 0.1, { x: 0, z: -10 }))).toBeCloseTo(Math.PI - 0.1)
+ })
+})
+
+describe('Waypoint', () => {
+ it('shows the target and its distance, and hides when there is none or you are there', () => {
+ document.body.innerHTML = '<div id="ui"></div>'
+ const root = document.getElementById('ui')!
+ const w = new Waypoint(root)
+ const el = root.querySelector('.waypoint') as HTMLElement
+ w.update({ x: 0, z: 0 }, 0, { label: 'The Forest', x: -100, z: 0 })
+ expect(el.hidden).toBe(false)
+ expect(el.textContent).toContain('The Forest · 100 m')
+ w.update({ x: -98, z: 0 }, 0, { label: 'The Forest', x: -100, z: 0 })
+ expect(el.hidden).toBe(true)
+ w.update({ x: 0, z: 0 }, 0, null)
+ expect(el.hidden).toBe(true)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+/**
+ * The first-steps waypoint: an arrow and a distance at the top of the screen toward the current step's target, so
+ * "the forest is west of town" becomes something to follow. Scrap Island's forest is over 100 m from the spawn, and a
+ * new scrapper had no marker to find it by.
+ */
+
+export interface WaypointTarget {
+ label: string
+ x: number
+ z: number
+}
+
+export interface NodeSighting {
+ def: string
+ name: string
+ x: number
+ z: number
+ remaining: number
+}
+
+export interface ZoneBox {
+ id: string
+ name: string
+ min: readonly number[]
+ max: readonly number[]
+}
+
+/**
+ * Where to point: the nearest sighted node of the wanted types that has something left (of `nodes`, preferring the
+ * ones in `prefer` when any are sighted), else the named zone's centre. Null when there is nothing to point at.
+ */
+export function pickTarget(
+ from: { x: number; z: number },
+ toward: { nodes?: readonly string[]; zone?: string; prefer?: readonly string[] },
+ nodes: Iterable<NodeSighting>,
+ zones: readonly ZoneBox[],
+): WaypointTarget | null {
+ const wanted = new Set(toward.nodes ?? [])
+ const preferred = new Set(toward.prefer ?? [])
+ let best: NodeSighting | null = null
+ let bestScore = Infinity
+ for (const n of nodes) {
+ if (!wanted.has(n.def) || n.remaining <= 0) continue
+ // A preferred type wins over any other; within a rank the nearest wins.
+ const score =
+ Math.hypot(n.x - from.x, n.z - from.z) + (preferred.size && !preferred.has(n.def) ? 1e6 : 0)
+ if (score < bestScore) {
+ best = n
+ bestScore = score
+ }
+ }
+ if (best) return { label: best.name, x: best.x, z: best.z }
+ const zone = toward.zone ? zones.find((z) => z.id === toward.zone) : undefined
+ if (zone)
+ return {
+ label: zone.name,
+ x: (zone.min[0]! + zone.max[0]!) / 2,
+ z: (zone.min[2]! + zone.max[2]!) / 2,
+ }
+ return null
+}
+
+/** The arrow's turn, radians clockwise from straight ahead, for a target seen from `from` facing `yaw` (0 = +z). */
+export function bearing(
+ from: { x: number; z: number },
+ yaw: number,
+ to: { x: number; z: number },
+): number {
+ const a = Math.atan2(to.x - from.x, to.z - from.z) - yaw
+ return Math.atan2(Math.sin(a), Math.cos(a))
+}
+
+export class Waypoint {
+ private readonly el: HTMLElement
+ private readonly arrow: HTMLElement
+ private readonly text: HTMLElement
+ private shown = ''
+
+ constructor(root: HTMLElement) {
+ this.el = document.createElement('div')
+ this.el.className = 'waypoint'
+ this.el.hidden = true
+ this.el.setAttribute('role', 'status')
+ this.arrow = document.createElement('span')
+ this.arrow.className = 'waypoint-arrow'
+ this.arrow.setAttribute('aria-hidden', 'true')
+ this.arrow.textContent = '↑'
+ this.text = document.createElement('span')
+ this.el.append(this.arrow, this.text)
+ root.appendChild(this.el)
+ }
+
+ /** Point at `target` from the player's position and facing; null hides it. Within 4 m it hides too (you're there). */
+ update(from: { x: number; z: number }, yaw: number, target: WaypointTarget | null): void {
+ const d = target ? Math.hypot(target.x - from.x, target.z - from.z) : 0
+ if (!target || d < 4) {
+ this.el.hidden = true
+ return
+ }
+ this.el.hidden = false
+ this.arrow.style.transform = `rotate(${bearing(from, yaw, target).toFixed(3)}rad)`
+ const label = `${target.label} · ${Math.round(d)} m`
+ if (label !== this.shown) {
+ this.text.textContent = label
+ this.shown = label
+ }
+ }
+}
@@ -91,6 +91,23 @@ export const ISLAND = {
@@ -91,6 +91,23 @@ export const ISLAND = {
spawn: [0, 0, -17] as Vec3,
spawn: [0, 0, -17] as Vec3,
}
}
+/** Named areas for the first-steps waypoint (zone id, name, [x, z] min, [x, z] max). */
+const LANDMARKS: [string, string, [number, number], [number, number]][] = [
+ [
+ 'forest',
+ 'The Forest',
+ [ISLAND.forest.center[0] - ISLAND.forest.radius, ISLAND.forest.center[1] - ISLAND.forest.radius],
+ [ISLAND.forest.center[0] + ISLAND.forest.radius, ISLAND.forest.center[1] + ISLAND.forest.radius],
+ ],
+ [
+ 'quarry',
+ 'The Quarry',
+ [ISLAND.quarry.center[0] - 22, ISLAND.quarry.center[1] - 22],
+ [ISLAND.quarry.center[0] + 22, ISLAND.quarry.center[1] + 22],
+ ],
+ ['scrapyard', 'The Scrapyard', [...ISLAND.scrapyard.min], [...ISLAND.scrapyard.max]],
+]
+
/** Dirt roads from the city gates out to each district (x, z polylines). */
/** Dirt roads from the city gates out to each district (x, z polylines). */
const ROADS: [number, number][][] = [
const ROADS: [number, number][][] = [
[
[
@@ -1031,6 +1048,15 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {
@@ -1031,6 +1048,15 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {
max: [W + 2, 60, W + 2],
max: [W + 2, 60, W + 2],
rules: { pvp: false, build: false, physgun: true },
rules: { pvp: false, build: false, physgun: true },
},
},
+ // Landmarks: named areas with the wilds' own rules, so they change nothing; the first-steps waypoint points at
+ // them until a tree, deposit or scrap pile is in sight, and the editor can move them with the map.
+ ...LANDMARKS.map(([id, name, [x0, z0], [x1, z1]]) => ({
+ id,
+ name,
+ min: [x0, -10, z0] as Vec3,
+ max: [x1, 80, z1] as Vec3,
+ rules: { pvp: true, build: true, physgun: true },
+ })),
],
],
models: [],
models: [],
textures: [],
textures: [],
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/client/src/ui/waypoint.test.ts'
+// covers: 'apps/client/src/ui'
+// Generated by node test/run.js --index; do not edit.