openvibers/openvibe.games Commits

Scraplandia's first steps: drink at the shore, cook a trail stew, hammer sheet metal and sell at Goose's post between the quarry and the haul (thirst and hunger ran out with nothing saying how to drink or eat, and the town's stations went unmentioned); waypoints point at props (#130)

OP OpenVibers committed 24 days ago · 5ff44fd6ac · 4 files, +171 −27

Changes

ddiff --git a/apps/client/src/game/interactionController.ts b/apps/client/src/game/interactionController.ts
iindex 70c5829..e98bb64 100644
---- a/apps/client/src/game/interactionController.ts
++++ b/apps/client/src/game/interactionController.ts
@@@ -68,6 +68,8 @@ export class InteractionController {
lastTargetWasPlayer = false
/** Hook: player pressed E on a trading post (entity id passed along). */
onShopOpen: ((targetId: string) => void) | null = null
++ /** When this client last asked to drink (ms), so the server's consume result can be told apart from eating. */
++ lastDrinkAt = 0
/** Rigging tool: first selected endpoint (highlight + prompt read this). */
riggingFirst: RiggingPick | null = null
/** Ghost preview pose supplier (wired by main; null = no valid ghost). */
@@@ -237,7 +239,10 @@ export class InteractionController {
}
const target = this.aim()
if (!target) {
-- if (this.standingInWater()) this.connection.send({ t: 'drink' })
++ if (this.standingInWater()) {
++ this.lastDrinkAt = Date.now()
++ this.connection.send({ t: 'drink' })
++ }
break
}
this.lastTargetWasPlayer = target.kind === 'player'
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex b666045..fb41321 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -62,7 +62,7 @@ import { registerRiggingModule } from './weapons/riggingModule.js'
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 { FirstSteps, towardOf } from './ui/firstSteps.js'
import { Waypoint, pickTarget, type NodeSighting } from './ui/waypoint.js'
/**
@@@ -241,6 +241,7 @@ async function start(): Promise<void> {
})
// Scraplandia's first-steps checklist (not in Salvage Rush or a creator world, whose worlds are something else).
let updateWaypoint: (() => void) | null = null
++ let firstStepsRef: FirstSteps | null = null
if (!worldId && content.world.id === 'scrap_island') {
const firstSteps = new FirstSteps(uiRoot, () => ({
count: (item) => state.countOf(item),
@@@ -249,6 +250,7 @@ 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)))
++ firstStepsRef = firstSteps
// The waypoint toward the current step's target (refreshed from the frame loop).
const waypoint = new Waypoint(uiRoot)
updateWaypoint = () => {
@@@ -263,24 +265,32 @@ async function start(): Promise<void> {
bird ? { label: 'Recovery bird', x: bird[0], z: bird[1] } : null,
)
}
-- if (!step.toward) return waypoint.update(from, player.viewYaw, null)
++ const toward = towardOf(step, firstSteps.view())
++ if (!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[] = []
++ // Props the step names (the campfire, the workbench, the trading post) count as sightings too.
++ const props = new Set(toward.props ?? [])
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,
-- })
++ if (!e.def) continue
++ if (e.kind === 'resource') {
++ 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,
++ })
++ } else if (e.kind === 'prop' && props.has(e.def)) {
++ sightings.push({ def: e.def, name: content.item(e.def)?.name ?? e.def, x: e.pos[0], z: e.pos[2], remaining: 1 })
++ }
}
const zones = getMapOverride(content.world)?.zones ?? []
-- waypoint.update(from, player.viewYaw, pickTarget(from, { ...step.toward, prefer }, sightings, zones))
++ const wanted = { nodes: [...(toward.nodes ?? []), ...props], ...(toward.zone ? { zone: toward.zone } : {}), prefer }
++ waypoint.update(from, player.viewYaw, pickTarget(from, wanted, sightings, zones))
}
}
const roundUi =
@@@ -470,6 +480,8 @@ async function start(): Promise<void> {
input.onWheel = (delta) => interact.onWheel(delta)
state.events.on('actionResult', (r) => {
++ // A drink the server accepted (eating answers the same action, so only right after this client asked to drink).
++ if (r.action === 'consume' && r.ok && Date.now() - interact.lastDrinkAt < 3000) firstStepsRef?.onDrink()
if (r.action === 'use' && r.ok && interact.lastTargetWasPlayer) {
interact.lastTargetWasPlayer = false
hud.flashHitmarker()
ddiff --git a/apps/client/src/ui/firstSteps.test.ts b/apps/client/src/ui/firstSteps.test.ts
iindex e73aacf..c01271b 100644
---- a/apps/client/src/ui/firstSteps.test.ts
++++ b/apps/client/src/ui/firstSteps.test.ts
@@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
--import { FIRST_STEPS, FirstSteps, progress } from './firstSteps.js'
++import { FIRST_STEPS, FirstSteps, progress, towardOf, type StepView } from './firstSteps.js'
class MemoryStorage {
private map = new Map<string, string>()
@@@ -55,14 +55,31 @@ describe('first steps', () => {
levels = {}
fs.update()
expect(current()).toContain('Mine at the quarry')
-- expect(root.querySelector('.first-steps-head')?.textContent).toContain('4/7')
++ expect(root.querySelector('.first-steps-head')?.textContent).toContain(
++ `4/${FIRST_STEPS.length}`,
++ )
})
-- it('finishes with a haul and then hides', () => {
++ it('then water, food, scrap, the workbench and the trading post, and finishes with a haul', () => {
const fs = new FirstSteps(root, read, storage as unknown as Storage)
-- items = { stone_axe: 1, scrap_metal: 5 }
++ items = { stone_axe: 1, stone_pickaxe: 1 }
levels = { woodcutting: 3, mining: 2 }
fs.update()
++ expect(current()).toContain('Drink at the shore')
++ expect(current()).toContain('shallow water and press E')
++ fs.onDrink()
++ expect(current()).toContain('Cook a Trail Stew')
++ items = { trail_stew: 1 }
++ fs.update()
++ expect(current()).toContain('Salvage the scrapyard')
++ items = { scrap_metal: 5 }
++ fs.update()
++ expect(current()).toContain('Hammer Sheet Metal')
++ items = { sheet_metal: 1 }
++ fs.update()
++ expect(current()).toContain("Sell at Goose's Trading Post")
++ items = { coin: 3 }
++ fs.update()
expect(current()).toContain('Extract a haul')
fs.onAnnounce('🚁 Extracted! 0 stacks secured · 0 haul points.')
expect(current()).toContain('Extract a haul')
@@@ -85,9 +102,30 @@ describe('first steps', () => {
})
it('progress() unions what was done with what is done now', () => {
-- const view = { count: () => 0, level: (s: string) => (s === 'mining' ? 2 : 1), hauled: false }
++ const view = {
++ count: () => 0,
++ level: (s: string) => (s === 'mining' ? 2 : 1),
++ hauled: false,
++ drank: false,
++ }
expect(progress(view, { done: ['gather'] })).toEqual(['gather', 'pickaxe', 'mine'])
})
++
++ it('keeps a scrapper who already made a haul past the steps added since', () => {
++ const view = { count: () => 0, level: () => 1, hauled: false, drank: false }
++ const before = ['gather', 'axe', 'pickaxe', 'chop', 'mine', 'scrap', 'haul']
++ expect(progress(view, { done: before })).toEqual(FIRST_STEPS.map((s) => s.id))
++ storage.setItem('scraplandia:first-steps', JSON.stringify({ done: before, hauled: true }))
++ new FirstSteps(root, read, storage as unknown as Storage).update()
++ expect((root.querySelector('.first-steps') as HTMLElement).hidden).toBe(true)
++ })
++
++ it('remembers a drink across a reload', () => {
++ const fs = new FirstSteps(root, read, storage as unknown as Storage)
++ fs.onDrink()
++ root.innerHTML = ''
++ expect(new FirstSteps(root, read, storage as unknown as Storage).view().drank).toBe(true)
++ })
})
describe('the waypoint hooks', () => {
@@@ -123,17 +161,47 @@ describe('the waypoint hooks', () => {
count: (i) => (i === 'stone_pickaxe' ? 1 : 0),
level: () => 1,
hauled: false,
++ drank: false,
}),
).toBe(true)
-- expect(pickaxe.done({ count: () => 0, level: () => 1, hauled: false })).toBe(false)
++ expect(pickaxe.done({ count: () => 0, level: () => 1, hauled: false, drank: false })).toBe(
++ false,
++ )
expect(FIRST_STEPS[0]?.hint).toMatch(/five stones/)
})
it('gives the outdoor steps somewhere to point', () => {
-- const toward = Object.fromEntries(FIRST_STEPS.map((s) => [s.id, s.toward]))
++ const view: StepView = { count: () => 0, level: () => 1, hauled: false, drank: false }
++ const toward = Object.fromEntries(FIRST_STEPS.map((s) => [s.id, towardOf(s, view)]))
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')
++ // The town's stations are props: the workbench, the trading post, and the campfire once there are berries.
++ expect(toward.stew).toEqual({ nodes: ['berry_bush'] })
++ const stew = FIRST_STEPS.find((s) => s.id === 'stew')!
++ expect(towardOf(stew, { ...view, count: (i) => (i === 'berries' ? 3 : 0) })).toEqual({
++ props: ['campfire'],
++ })
++ expect(toward.sheet).toEqual({ props: ['workbench'] })
++ expect(toward.trade).toEqual({ props: ['merchant_stall'] })
++ expect(toward.drink).toBeUndefined()
++ })
++
++ it('puts water and food before the scrapyard, and the workbench and trading post before the haul', () => {
++ const ids = FIRST_STEPS.map((s) => s.id)
++ expect(ids).toEqual([
++ 'gather',
++ 'axe',
++ 'pickaxe',
++ 'chop',
++ 'mine',
++ 'drink',
++ 'stew',
++ 'scrap',
++ 'sheet',
++ 'trade',
++ 'haul',
++ ])
})
})
ddiff --git a/apps/client/src/ui/firstSteps.ts b/apps/client/src/ui/firstSteps.ts
iindex aa85aa5..fc82afa 100644
---- a/apps/client/src/ui/firstSteps.ts
++++ b/apps/client/src/ui/firstSteps.ts
@@@ -3,9 +3,11 @@
* used to arrive with an empty hotbar and nothing to say what Scraplandia is about (owner, 2026-10-19: "so boring").
*
* The steps follow Scrap Island (apps/server/src/maps/scrapIsland.ts) and the tool chain: branches and loose stones
-- * need no tool → planks → a Stone Axe and a Stone Pickaxe → an oak in the west forest → the quarry → the scrapyard →
-- * a haul at the recovery bird. Each step reads what the client already knows (inventory, skill levels, the
-- * extraction announce); progress only moves forward and is kept in this browser. The panel can be closed for good.
++ * need no tool → planks → a Stone Axe and a Stone Pickaxe → an oak in the west forest → the quarry → water and food
++ * (thirst empties in 30 minutes and hunger in 40, and nothing said how to drink or eat) → the scrapyard → the town's
++ * workbench and Goose's Trading Post (both stood in town unmentioned) → a haul at the recovery bird. Each step reads
++ * what the client already knows (inventory, skill levels, a drink the server confirmed, the extraction announce);
++ * progress only moves forward and is kept in this browser. The panel can be closed for good.
*/
export interface StepView {
@@@ -15,6 +17,15 @@ export interface StepView {
level: (skill: string) => number
/** Set once an extraction secured something. */
hauled: boolean
++ /** Set once the server confirmed a drink of water. */
++ drank: boolean
++}
++
++/** What the waypoint points at: nodes of these types with something left, props of these kinds, else a zone. */
++export interface Toward {
++ nodes?: string[]
++ props?: string[]
++ zone?: string
}
export interface FirstStep {
@@@ -23,10 +34,11 @@ export interface FirstStep {
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.
++ * Where the waypoint points (ui/waypoint.ts): the nearest node of these types that has something left (or prop of
++ * these kinds: a campfire, the workbench, the trading post), else the map zone with this id (a landmark). A
++ * function picks by what the player holds, e.g. berry bushes until there are enough berries, then the campfire.
*/
-- toward?: { nodes?: string[]; zone?: string }
++ toward?: Toward | ((v: StepView) => Toward)
}
export const FIRST_STEPS: FirstStep[] = [
@@@ -67,6 +79,19 @@ export const FIRST_STEPS: FirstStep[] = [
done: (v) => v.level('mining') >= 2,
toward: { nodes: ['stone_deposit'], zone: 'quarry' },
},
++ {
++ id: 'drink',
++ title: 'Drink at the shore',
++ hint: 'Your H2O bar runs dry in about half an hour. Wade into shallow water and press E with nothing in front of you.',
++ done: (v) => v.drank,
++ },
++ {
++ id: 'stew',
++ title: 'Cook a Trail Stew',
++ hint: 'Food lasts about 40 minutes. Pick 3 berries from the bushes (E), then at the campfire in town: Tab → Crafting, 3 berries and a log.',
++ done: (v) => v.count('trail_stew') > 0,
++ toward: (v) => (v.count('berries') >= 3 ? { props: ['campfire'] } : { nodes: ['berry_bush'] }),
++ },
{
id: 'scrap',
title: 'Salvage the scrapyard',
@@@ -74,6 +99,20 @@ export const FIRST_STEPS: FirstStep[] = [
done: (v) => v.count('scrap_metal') >= 4 || v.level('scavenging') >= 2,
toward: { nodes: ['scrap_pile'], zone: 'scrapyard' },
},
++ {
++ id: 'sheet',
++ title: 'Hammer Sheet Metal',
++ hint: 'At the workbench in town, Tab → Crafting: 3 scrap make a sheet, the start of metal walls, water tanks and a generator.',
++ done: (v) => v.count('sheet_metal') > 0,
++ toward: { props: ['workbench'] },
++ },
++ {
++ id: 'trade',
++ title: "Sell at Goose's Trading Post",
++ hint: "The goose's stall in town buys stone, logs, planks and scrap for bottle caps, and sells seeds, rope, stew and bandages. Look at it and press E.",
++ done: (v) => v.count('coin') > 0,
++ toward: { props: ['merchant_stall'] },
++ },
{
id: 'haul',
title: 'Extract a haul',
@@@ -88,6 +127,7 @@ interface Saved {
done: string[]
closed?: boolean
hauled?: boolean
++ drank?: boolean
}
function load(storage: Storage | null): Saved {
@@@ -103,18 +143,25 @@ function load(storage: Storage | null): Saved {
/** Steps done so far, never fewer than before (progress only moves forward). */
export function progress(view: StepView, saved: Saved): string[] {
++ // A scrapper who already made a haul is past the whole list, the steps added since included.
++ if (saved.done.includes('haul')) return FIRST_STEPS.map((s) => s.id)
const done = new Set(saved.done)
for (const step of FIRST_STEPS) if (step.done(view)) done.add(step.id)
return FIRST_STEPS.map((s) => s.id).filter((id) => done.has(id))
}
++/** Where a step's waypoint points for what the player holds now (no target: undefined). */
++export function towardOf(step: FirstStep, view: StepView): Toward | undefined {
++ return typeof step.toward === 'function' ? step.toward(view) : step.toward
++}
++
export class FirstSteps {
private readonly el: HTMLElement
private saved: Saved
constructor(
root: HTMLElement,
-- private readonly read: () => Omit<StepView, 'hauled'>,
++ private readonly read: () => Omit<StepView, 'hauled' | 'drank'>,
private readonly storage: Storage | null = safeStorage(),
) {
this.saved = load(storage)
@@@ -134,7 +181,7 @@ export class FirstSteps {
/** Re-check the steps (inventory or skills changed). */
update(): void {
-- const view = { ...this.read(), hauled: !!this.saved.hauled }
++ const view = this.view()
const done = progress(view, this.saved)
if (done.length !== this.saved.done.length) {
this.saved.done = done
@@@ -143,6 +190,18 @@ export class FirstSteps {
this.render()
}
++ /** What the steps read: the client's state and the flags this browser keeps. */
++ view(): StepView {
++ return { ...this.read(), hauled: !!this.saved.hauled, drank: !!this.saved.drank }
++ }
++
++ /** The server confirmed a drink of water. */
++ onDrink(): void {
++ if (this.saved.drank) return
++ this.saved.drank = true
++ this.update()
++ }
++
/** 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