openvibers/openvibe.games Commits

Scraplandia: a first-steps checklist for new scrappers (gather, a Stone Axe, an oak, the quarry, the scrapyard, a haul) and branch and stone piles just inside every gate (#109)

OP OpenVibers committed 25 days ago · 3abe57051d · 9 files, +424 −0

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 4e988fd..8cb897e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -18,6 +18,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
++- **First steps.** A new scrapper gets a checklist in the corner:
++ - gather branches and stones (piles just inside every city gate);
++ - craft a Stone Axe;
++ - chop an oak in the west forest;
++ - mine at the quarry;
++ - salvage the scrapyard;
++ - extract a haul.
++
++ Each step says where to go. Progress only moves forward and is kept in the browser, and the list can be closed.
- **Scrap Island, Scraplandia's world (2026-10-19).** Production ran on an empty map document, so a new player
spawned over open water and drowned within seconds. `apps/server/src/maps/scrapIsland.ts` generates the island as an
ordinary v2 map document, the file the editor saves, so staff keep editing it in `/editor`. It is deterministic.
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex 9ef41ab..ab57b90 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -59,6 +59,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'
/**
* Client bootstrap: engine/scene -> character customization (live preview)
@@@ -234,6 +235,16 @@ async function start(): Promise<void> {
guest: identity.token,
...(sso ? { bearer: sso } : {}),
})
++ // Scraplandia's first-steps checklist (not in Salvage Rush or a creator world, whose worlds are something else).
++ if (!worldId && content.world.id === 'scrap_island') {
++ const firstSteps = new FirstSteps(uiRoot, () => ({
++ count: (item) => state.countOf(item),
++ level: (skill) => state.skillLevel(skill),
++ }))
++ state.events.on('inventory', () => firstSteps.update())
++ state.events.on('skills', () => firstSteps.update())
++ state.events.on('announce', (text) => firstSteps.onAnnounce(String(text)))
++ }
const roundUi =
content.world.id === 'salvage_rush_arena'
? await import('./ui/salvageRushHud.js').then(
ddiff --git a/apps/client/src/ui/firstSteps.test.ts b/apps/client/src/ui/firstSteps.test.ts
nnew file mode 100644
iindex 0000000..a4e72b2
---- /dev/null
++++ b/apps/client/src/ui/firstSteps.test.ts
@@@ -0,0 +1,88 @@
++// @vitest-environment jsdom
++import { beforeEach, describe, expect, it } from 'vitest'
++import { FIRST_STEPS, FirstSteps, progress } from './firstSteps.js'
++
++class MemoryStorage {
++ private map = new Map<string, string>()
++ getItem(k: string): string | null {
++ return this.map.get(k) ?? null
++ }
++ setItem(k: string, v: string): void {
++ this.map.set(k, v)
++ }
++}
++
++let items: Record<string, number>
++let levels: Record<string, number>
++const read = () => ({ count: (i: string) => items[i] ?? 0, level: (s: string) => levels[s] ?? 1 })
++let root: HTMLElement
++let storage: MemoryStorage
++
++beforeEach(() => {
++ items = {}
++ levels = {}
++ document.body.innerHTML = '<div id="ui"></div>'
++ root = document.getElementById('ui')!
++ storage = new MemoryStorage()
++})
++const steps = () => [...root.querySelectorAll('.first-steps li')]
++const current = () => root.querySelector('.first-steps li.current')?.textContent ?? ''
++
++describe('first steps', () => {
++ it('starts on gathering, with the hint for that step only', () => {
++ new FirstSteps(root, read, storage as unknown as Storage)
++ expect(steps()).toHaveLength(FIRST_STEPS.length)
++ expect(current()).toContain('Gather branches and stones')
++ expect(current()).toContain('inside every city gate')
++ expect(root.querySelectorAll('.first-steps .hint')).toHaveLength(1)
++ })
++
++ it('moves on as the player gathers, crafts and levels, and never goes back', () => {
++ const fs = new FirstSteps(root, read, storage as unknown as Storage)
++ items = { wood_log: 2, stone: 3 }
++ fs.update()
++ expect(current()).toContain('Craft a Stone Axe')
++ items = { stone_axe: 1 }
++ fs.update()
++ expect(current()).toContain('Chop an oak')
++ levels = { woodcutting: 2 }
++ fs.update()
++ expect(current()).toContain('Mine at the quarry')
++ items = {} // dropped everything: progress stays
++ levels = {}
++ fs.update()
++ expect(current()).toContain('Mine at the quarry')
++ expect(root.querySelector('.first-steps-head')?.textContent).toContain('3/6')
++ })
++
++ it('finishes with a haul and then hides', () => {
++ const fs = new FirstSteps(root, read, storage as unknown as Storage)
++ items = { stone_axe: 1, scrap_metal: 5 }
++ levels = { woodcutting: 3, mining: 2 }
++ fs.update()
++ expect(current()).toContain('Extract a haul')
++ fs.onAnnounce('🚁 Extracted! 0 stacks secured · 0 haul points.')
++ expect(current()).toContain('Extract a haul')
++ fs.onAnnounce('🚁 Extracted! 2 stacks secured · 140 haul points.')
++ expect((root.querySelector('.first-steps') as HTMLElement).hidden).toBe(true)
++ })
++
++ it('keeps progress in the browser and stays closed once closed', () => {
++ const fs = new FirstSteps(root, read, storage as unknown as Storage)
++ items = { wood_log: 1, stone: 1 }
++ fs.update()
++ root.innerHTML = ''
++ new FirstSteps(root, read, storage as unknown as Storage)
++ expect(current()).toContain('Craft a Stone Axe')
++ ;(root.querySelector('[data-first-steps-close]') as HTMLButtonElement).click()
++ expect((root.querySelector('.first-steps') as HTMLElement).hidden).toBe(true)
++ root.innerHTML = ''
++ new FirstSteps(root, read, storage as unknown as Storage)
++ expect((root.querySelector('.first-steps') as HTMLElement).hidden).toBe(true)
++ })
++
++ it('progress() unions what was done with what is done now', () => {
++ const view = { count: () => 0, level: (s: string) => (s === 'mining' ? 2 : 1), hauled: false }
++ expect(progress(view, { done: ['gather'] })).toEqual(['gather', 'mine'])
++ })
++})
ddiff --git a/apps/client/src/ui/firstSteps.ts b/apps/client/src/ui/firstSteps.ts
nnew file mode 100644
iindex 0000000..149aeeb
---- /dev/null
++++ b/apps/client/src/ui/firstSteps.ts
@@@ -0,0 +1,193 @@
++/**
++ * First steps: a short checklist in the corner for a new scrapper, so the first minutes have a direction. A guest
++ * 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 → an oak in the west forest → a Stone Pickaxe → 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.
++ */
++
++export interface StepView {
++ /** Items held, by def id. */
++ count: (item: string) => number
++ /** Skill level (1 when untrained). */
++ level: (skill: string) => number
++ /** Set once an extraction secured something. */
++ hauled: boolean
++}
++
++export interface FirstStep {
++ id: string
++ title: string
++ hint: string
++ done: (v: StepView) => boolean
++}
++
++export const FIRST_STEPS: FirstStep[] = [
++ {
++ id: 'gather',
++ title: 'Gather branches and stones',
++ hint: 'Piles just inside every city gate. Look at one and press E.',
++ 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),
++ },
++ {
++ id: 'axe',
++ title: 'Craft a Stone Axe',
++ hint: 'Tab → Crafting: turn a log into Wood Planks, then make the axe.',
++ done: (v) => v.count('stone_axe') > 0 || v.level('woodcutting') >= 2,
++ },
++ {
++ id: 'chop',
++ title: 'Chop an oak',
++ hint: 'The forest is west of town. Hold the axe and swing (left click).',
++ done: (v) => v.level('woodcutting') >= 2,
++ },
++ {
++ 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,
++ },
++ {
++ 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,
++ },
++ {
++ id: 'haul',
++ title: 'Extract a haul',
++ hint: 'When the recovery bird circles, stand in its circle with your valuables. Hauls score on the weekly board.',
++ done: (v) => v.hauled,
++ },
++]
++
++const STORAGE_KEY = 'scraplandia:first-steps'
++
++interface Saved {
++ done: string[]
++ closed?: boolean
++ hauled?: boolean
++}
++
++function load(storage: Storage | null): Saved {
++ try {
++ const raw = storage?.getItem(STORAGE_KEY)
++ const saved = raw ? (JSON.parse(raw) as Saved) : null
++ if (saved && Array.isArray(saved.done)) return saved
++ } catch {
++ // unreadable or blocked storage: start fresh
++ }
++ return { done: [] }
++}
++
++/** Steps done so far, never fewer than before (progress only moves forward). */
++export function progress(view: StepView, saved: Saved): string[] {
++ 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))
++}
++
++export class FirstSteps {
++ private readonly el: HTMLElement
++ private saved: Saved
++
++ constructor(
++ root: HTMLElement,
++ private readonly read: () => Omit<StepView, 'hauled'>,
++ private readonly storage: Storage | null = safeStorage(),
++ ) {
++ this.saved = load(storage)
++ this.el = document.createElement('section')
++ this.el.className = 'first-steps'
++ this.el.setAttribute('aria-label', 'First steps')
++ root.appendChild(this.el)
++ this.el.addEventListener('click', (e) => {
++ if ((e.target as HTMLElement).closest('[data-first-steps-close]')) {
++ this.saved.closed = true
++ this.save()
++ this.render()
++ }
++ })
++ this.render()
++ }
++
++ /** Re-check the steps (inventory or skills changed). */
++ update(): void {
++ const view = { ...this.read(), hauled: !!this.saved.hauled }
++ const done = progress(view, this.saved)
++ if (done.length !== this.saved.done.length) {
++ this.saved.done = done
++ this.save()
++ }
++ this.render()
++ }
++
++ /** A server announce: an extraction that secured something completes the last step. */
++ onAnnounce(text: string): void {
++ if (/Extracted! [1-9]/.test(text)) {
++ this.saved.hauled = true
++ this.update()
++ }
++ }
++
++ private save(): void {
++ try {
++ this.storage?.setItem(STORAGE_KEY, JSON.stringify(this.saved))
++ } catch {
++ // storage full or blocked: progress lives for this session
++ }
++ }
++
++ private render(): void {
++ const done = new Set(this.saved.done)
++ const allDone = FIRST_STEPS.every((s) => done.has(s.id))
++ if (this.saved.closed || allDone) {
++ this.el.hidden = true
++ return
++ }
++ this.el.hidden = false
++ const current = FIRST_STEPS.find((s) => !done.has(s.id))
++ const head = document.createElement('div')
++ head.className = 'first-steps-head'
++ const title = document.createElement('span')
++ title.textContent = `First steps · ${done.size}/${FIRST_STEPS.length}`
++ const close = document.createElement('button')
++ close.type = 'button'
++ close.dataset.firstStepsClose = '1'
++ close.setAttribute('aria-label', 'Hide first steps')
++ close.textContent = '×'
++ head.append(title, close)
++ const list = document.createElement('ol')
++ for (const step of FIRST_STEPS) {
++ const li = document.createElement('li')
++ const isDone = done.has(step.id)
++ li.className = isDone ? 'done' : step === current ? 'current' : ''
++ const mark = document.createElement('span')
++ mark.className = 'mark'
++ mark.textContent = isDone ? '✓' : step === current ? '›' : '·'
++ const text = document.createElement('span')
++ text.textContent = step.title
++ li.append(mark, text)
++ if (step === current) {
++ const hint = document.createElement('div')
++ hint.className = 'hint'
++ hint.textContent = step.hint
++ li.appendChild(hint)
++ }
++ list.appendChild(li)
++ }
++ this.el.replaceChildren(head, list)
++ }
++}
++
++function safeStorage(): Storage | null {
++ try {
++ return globalThis.localStorage ?? null
++ } catch {
++ return null
++ }
++}
ddiff --git a/apps/client/src/ui/styles.css b/apps/client/src/ui/styles.css
iindex 3179edd..ff39a4b 100644
---- a/apps/client/src/ui/styles.css
++++ b/apps/client/src/ui/styles.css
@@@ -961,6 +961,88 @@ body.ovg-frame-hidden #ov-footer {
opacity: 1;
}
++/* ── First steps (ui/firstSteps.ts) ───────────────────────── */
++.first-steps {
++ position: absolute;
++ top: 56px;
++ right: 16px;
++ width: min(290px, calc(100vw - 32px));
++ padding: 10px 12px 8px;
++ border-radius: 10px;
++ background: rgba(20, 26, 34, 0.86);
++ border: 1px solid rgba(232, 181, 74, 0.55);
++ color: #e8e2d4;
++ font-size: 13px;
++ line-height: 1.35;
++ pointer-events: auto;
++}
++.first-steps[hidden] {
++ display: none;
++}
++.first-steps-head {
++ display: flex;
++ align-items: center;
++ justify-content: space-between;
++ margin-bottom: 6px;
++ font-weight: 700;
++ letter-spacing: 0.04em;
++ text-transform: uppercase;
++ font-size: 11px;
++ color: #e8b54a;
++}
++.first-steps-head button {
++ background: none;
++ border: 0;
++ color: #b9b2a3;
++ font-size: 18px;
++ line-height: 1;
++ cursor: pointer;
++ padding: 0 2px;
++}
++.first-steps-head button:hover {
++ color: #fff;
++}
++.first-steps ol {
++ list-style: none;
++ margin: 0;
++ padding: 0;
++}
++.first-steps li {
++ padding: 2px 0;
++ color: #a8a194;
++}
++.first-steps li .mark {
++ display: inline-block;
++ width: 1.2em;
++ color: #6f6a60;
++}
++.first-steps li.done {
++ color: #7f9b6e;
++}
++.first-steps li.done .mark {
++ color: #8fc06f;
++}
++.first-steps li.current {
++ color: #fff;
++ font-weight: 600;
++}
++.first-steps li.current .mark {
++ color: #e8b54a;
++}
++.first-steps .hint {
++ margin: 2px 0 4px 1.2em;
++ font-weight: 400;
++ color: #cfc8b8;
++ font-size: 12px;
++}
++@media (max-width: 640px) {
++ .first-steps {
++ top: auto;
++ bottom: 96px;
++ font-size: 12px;
++ }
++}
++
/* ── Death screen ─────────────────────────────────────────────────── */
.death-screen {
position: absolute;
ddiff --git a/apps/server/src/maps/scrapIsland.test.ts b/apps/server/src/maps/scrapIsland.test.ts
iindex a19d564..8b284d3 100644
---- a/apps/server/src/maps/scrapIsland.test.ts
++++ b/apps/server/src/maps/scrapIsland.test.ts
@@@ -39,6 +39,22 @@ describe('Scrap Island', () => {
expect(map.statics.filter((s) => s.decor === 'lamp').length).toBeLessThanOrEqual(12)
})
++ it('puts branches and loose stones (no tool needed) inside every gate, in the safe zone', () => {
++ const city = map.zones.find((zone) => zone.id === 'scrap-city')!
++ for (const [gx, gz] of [
++ [0, ISLAND.wall],
++ [0, -ISLAND.wall],
++ [ISLAND.wall, 0],
++ [-ISLAND.wall, 0],
++ ] as const) {
++ const near = map.nodes.filter((n) => Math.hypot(n.pos[0] - gx, n.pos[2] - gz) < 10)
++ expect(near.filter((n) => n.node === 'branch_pile').length).toBeGreaterThanOrEqual(2)
++ expect(near.filter((n) => n.node === 'loose_stones').length).toBeGreaterThanOrEqual(2)
++ for (const n of near)
++ expect(Math.max(Math.abs(n.pos[0]), Math.abs(n.pos[2]))).toBeLessThan(city.max[0])
++ }
++ })
++
it('is an island: land in the middle, sea at the edges, the city flat', () => {
const t = map.terrains[0]!
const h = decodeHeights(t.heights)
ddiff --git a/apps/server/src/maps/scrapIsland.ts b/apps/server/src/maps/scrapIsland.ts
iindex 4060d57..b98eebf 100644
---- a/apps/server/src/maps/scrapIsland.ts
++++ b/apps/server/src/maps/scrapIsland.ts
@@@ -600,6 +600,26 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {
color: '#6e4a36',
})
}
++ // Starter piles just inside each gate: fallen branches and loose stones, which need no tool, so a new player's first
++ // planks and stone axe are a short walk from spawn.
++ for (const [ax, az] of [
++ [0, 1],
++ [0, -1],
++ [1, 0],
++ [-1, 0],
++ ] as const) {
++ const at = (along: number, depth: number): [number, number] =>
++ ax === 0 ? [along, az * depth] : [ax * depth, along]
++ for (const [along, depth, kind] of [
++ [-6.2, 34.5, 'branch_pile'],
++ [6.2, 34.5, 'loose_stones'],
++ [-6.2, 31.5, 'loose_stones'],
++ [6.2, 31.5, 'branch_pile'],
++ ] as const) {
++ const [x, z] = at(along, depth)
++ node(kind, x, z)
++ }
++ }
// Lamps: down each avenue and round the plaza (each one is a point light at night, so not too many).
for (const [x, z] of [
[5, 16],
ddiff --git a/docs/games.md b/docs/games.md
iindex ef40c53..ee8d731 100644
---- a/docs/games.md
++++ b/docs/games.md
@@@ -32,6 +32,8 @@ file without `--force`, because that file may hold staff edits.
- the farm to the south-east;
- the shipwreck beach to the south;
- ruins along the dirt roads.
++- **Starter piles**: two fallen-branch and two loose-stone piles just inside each gate, which need no tool, so a new player's first planks and Stone Axe are close to spawn.
++- **First steps** (`apps/client/src/ui/firstSteps.ts`): a checklist in the corner for a new scrapper. The steps are gather, a Stone Axe, an oak (woodcutting level 2), the quarry (mining level 2), the scrapyard and a haul. Progress only moves forward, it's kept in the browser, and the list can be closed.
- **NPCs**: the content's NPC homes fit the layout. Thugs and hares are just outside the walls, wardens and
townsfolk on the plaza.
- **World id**: `scrap_island`. Changing the id re-seeds resources and moves characters to spawn on the next boot.
ddiff --git a/test/apps__client__src__ui__firstSteps.test.js b/test/apps__client__src__ui__firstSteps.test.js
nnew file mode 100644
iindex 0000000..21869af
---- /dev/null
++++ b/test/apps__client__src__ui__firstSteps.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/client/src/ui/firstSteps.test.ts'
++// covers: 'apps/client/src/ui'
++// Generated by node test/run.js --index; do not edit.