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)

OP OpenVibers committed 24 days ago · 7e8228de91 · 9 files, +347 −1

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 94f276f..359aa4d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 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.
The workbench has legs, a shelf, a vise and a saw, the still has a dome and a pipe, planters grow, and the campfire
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex 7d46933..b666045 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -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 = null
if (!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 = 0
let wasGrounded = true
let fallSpeed = 0
@@@ -652,6 +688,10 @@ async function start(): Promise<void> {
}
wasGrounded = grounded
sfx.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
ddiff --git a/apps/client/src/ui/firstSteps.test.ts b/apps/client/src/ui/firstSteps.test.ts
iindex a4e72b2..16df650 100644
---- a/apps/client/src/ui/firstSteps.test.ts
++++ b/apps/client/src/ui/firstSteps.test.ts
@@@ -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')
++ })
++})
ddiff --git a/apps/client/src/ui/firstSteps.ts b/apps/client/src/ui/firstSteps.ts
iindex 149aeeb..d12edd9 100644
---- a/apps/client/src/ui/firstSteps.ts
++++ b/apps/client/src/ui/firstSteps.ts
@@@ -22,6 +22,11 @@ export interface FirstStep {
title: string
hint: string
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[] = [
@@@ -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 = null
if (/Extracted! [1-9]/.test(text)) {
this.saved.hauled = true
this.update()
ddiff --git a/apps/client/src/ui/styles.css b/apps/client/src/ui/styles.css
iindex 1f28e6b..f20238c 100644
---- a/apps/client/src/ui/styles.css
++++ b/apps/client/src/ui/styles.css
@@@ -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;
ddiff --git a/apps/client/src/ui/waypoint.test.ts b/apps/client/src/ui/waypoint.test.ts
nnew file mode 100644
iindex 0000000..b91a009
---- /dev/null
++++ b/apps/client/src/ui/waypoint.test.ts
@@@ -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)
++ })
++})
ddiff --git a/apps/client/src/ui/waypoint.ts b/apps/client/src/ui/waypoint.ts
nnew file mode 100644
iindex 0000000..2a68c49
---- /dev/null
++++ b/apps/client/src/ui/waypoint.ts
@@@ -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
++ }
++ }
++}
ddiff --git a/apps/server/src/maps/scrapIsland.ts b/apps/server/src/maps/scrapIsland.ts
iindex 67b021b..3875f4b 100644
---- a/apps/server/src/maps/scrapIsland.ts
++++ b/apps/server/src/maps/scrapIsland.ts
@@@ -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: [],
ddiff --git a/test/apps__client__src__ui__waypoint.test.js b/test/apps__client__src__ui__waypoint.test.js
nnew file mode 100644
iindex 0000000..1574c77
---- /dev/null
++++ b/test/apps__client__src__ui__waypoint.test.js
@@@ -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.