openvibers/openvibe.games Commits

New creator worlds start on ground; top-down worlds draw a ring under each player (#167)

OP OpenVibers committed 17 days ago · eca92e4271 · 8 files, +185 −5

* New creator worlds start on flat grass over the whole play area (an empty map left players on the safety floor under the sea); top-down worlds draw a ring under each player with a notch the way it faces

* creatorWorld.test: the pack's map is optional in its type

Changes

ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex 99ea1a1..b4898f8 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -27,6 +27,7 @@ import { LocalPlayer } from './game/localPlayer.js'
import { InputTracker } from './input/inputTracker.js'
import { KeyboardInputSource, SwitchableInputSource } from './input/inputSource.js'
import { TopDownInputSource } from './input/topDownInputSource.js'
++import { TopDownMarkers } from './render/topDownMarkers.js'
import { TouchControls } from './input/touchControls.js'
import { TouchInputSource, TouchPad } from './input/touchInputSource.js'
import { Connection, gameSocketUrl, getIdentity, saveName } from './net/connection.js'
@@@ -405,6 +406,8 @@ async function start(): Promise<void> {
environment.hazeFog = false
hud.setCrosshair(false)
}
++ // From above, a ring under each player (yours blue) shows who is where and which way each one faces.
++ const markers = topDown ? new TopDownMarkers(scene, state, view, player) : null
const beams = new BeamRenderer(scene)
const constraintLines = new ConstraintLineRenderer(scene, state, view)
const ghost = new PlacementGhost(scene, physics, content)
@@@ -703,6 +706,7 @@ async function start(): Promise<void> {
physics.step(0) // query-only world: refresh broadphase, no dynamics
player.frameUpdate(timestep.alpha, elapsed)
view.update(now / 1000)
++ markers?.update()
// First-person presentation: body, viewmodel, beams.
const selfHolding = [...state.heldBy.values()].includes(state.myEntityId)
ddiff --git a/apps/client/src/render/avatar/avatar.ts b/apps/client/src/render/avatar/avatar.ts
iindex 7dfd005..56560a9 100644
---- a/apps/client/src/render/avatar/avatar.ts
++++ b/apps/client/src/render/avatar/avatar.ts
@@@ -131,6 +131,11 @@ export class Avatar {
return this.rig.joints.root.position
}
++ /** The way the body faces (radians), as last updated. */
++ get yaw(): number {
++ return this.rig.joints.root.rotation.y
++ }
++
/** Rebuilds the rig (customization preview edits). Preserves pose state loosely. */
setAppearance(appearance: Appearance): void {
this.appearance = appearance
ddiff --git a/apps/client/src/render/topDownMarkers.ts b/apps/client/src/render/topDownMarkers.ts
nnew file mode 100644
iindex 0000000..894471c
---- /dev/null
++++ b/apps/client/src/render/topDownMarkers.ts
@@@ -0,0 +1,109 @@
++import { Color3 } from '@babylonjs/core/Maths/math.color.js'
++import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'
++import { CreateCylinder } from '@babylonjs/core/Meshes/Builders/cylinderBuilder.js'
++import { CreateTube } from '@babylonjs/core/Meshes/Builders/tubeBuilder.js'
++import { TransformNode } from '@babylonjs/core/Meshes/transformNode.js'
++import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
++import type { Scene } from '@babylonjs/core/scene.js'
++import { hullHeightFor } from '@openvibe/gameplay'
++import type { LocalPlayer } from '../game/localPlayer.js'
++import type { ClientState } from '../state/clientState.js'
++import type { EntityView } from './entityView.js'
++
++/** Your marker, and everyone else's. */
++const SELF = new Color3(0.31, 0.66, 1)
++const OTHER = new Color3(0.94, 0.94, 0.9)
++const RADIUS = 0.62
++/** Just above the feet, so the ground never covers it and the body stands on it. */
++const LIFT = 0.04
++
++/**
++ * Seen from above (a creator world's top-down view), a person is a few pixels of hair and shoulders. Each player gets a
++ * flat ring at their feet with a notch on the side they face: yours in blue, everyone else's in white, so you find
++ * yourself and the others at a glance and see which way each one looks. Drawn only in a top-down world, unlit, never
++ * picked by the aim ray.
++ */
++export class TopDownMarkers {
++ private readonly markers = new Map<string, TransformNode>()
++ private readonly selfMat: StandardMaterial
++ private readonly otherMat: StandardMaterial
++
++ constructor(
++ private readonly scene: Scene,
++ private readonly state: ClientState,
++ private readonly view: EntityView,
++ private readonly player: LocalPlayer,
++ ) {
++ this.selfMat = this.material('top-down-self', SELF)
++ this.otherMat = this.material('top-down-other', OTHER)
++ }
++
++ private material(name: string, color: Color3): StandardMaterial {
++ const m = new StandardMaterial(name, this.scene)
++ m.disableLighting = true
++ m.emissiveColor = color
++ m.diffuseColor = color
++ m.specularColor = Color3.Black()
++ return m
++ }
++
++ /** A ring and, at its front (+Z, the way the owner faces), a notch. */
++ private make(id: string, mat: StandardMaterial): TransformNode {
++ const root = new TransformNode(`top-down:${id}`, this.scene)
++ const path: Vector3[] = []
++ for (let i = 0; i <= 48; i++) {
++ const a = (i / 48) * Math.PI * 2
++ path.push(new Vector3(Math.sin(a) * RADIUS, 0, Math.cos(a) * RADIUS))
++ }
++ const ring = CreateTube(
++ `top-down:${id}:ring`,
++ { path, radius: 0.06, tessellation: 6 },
++ this.scene,
++ )
++ // A three-sided cylinder lying flat is a triangle seen from above: the notch, pointing forward.
++ const notch = CreateCylinder(
++ `top-down:${id}:notch`,
++ { height: 0.04, diameter: 0.42, tessellation: 3 },
++ this.scene,
++ )
++ notch.position.z = RADIUS + 0.16
++ notch.rotation.y = Math.PI / 2
++ for (const mesh of [ring, notch]) {
++ mesh.material = mat
++ mesh.isPickable = false
++ mesh.parent = root
++ }
++ return root
++ }
++
++ private markerFor(id: string, mat: StandardMaterial): TransformNode {
++ let m = this.markers.get(id)
++ if (!m) {
++ m = this.make(id, mat)
++ this.markers.set(id, m)
++ }
++ return m
++ }
++
++ /** Per frame, after the entity view and the local player moved. */
++ update(): void {
++ const me = this.markerFor('self', this.selfMat)
++ const pos = this.player.renderPos
++ me.position.set(pos.x, pos.y - hullHeightFor(this.player.move.stance) / 2 + LIFT, pos.z)
++ me.rotation.y = this.player.viewYaw
++ for (const [id, e] of this.state.entities) {
++ if (e.kind !== 'player' || id === this.state.myEntityId) continue
++ const avatar = this.view.avatarFor(id)
++ if (!avatar) continue
++ const m = this.markerFor(id, this.otherMat)
++ const feet = avatar.rootPosition
++ m.position.set(feet.x, feet.y + LIFT, feet.z)
++ m.rotation.y = avatar.yaw
++ }
++ for (const [id, m] of this.markers)
++ if (id !== 'self' && !this.state.entities.has(id)) {
++ m.dispose()
++ this.markers.delete(id)
++ }
++ }
++}
ddiff --git a/apps/server/src/worlds/worldRoutes.test.ts b/apps/server/src/worlds/worldRoutes.test.ts
iindex 0942115..5f44a6f 100644
---- a/apps/server/src/worlds/worldRoutes.test.ts
++++ b/apps/server/src/worlds/worldRoutes.test.ts
@@@ -252,6 +252,15 @@ describe('/api/worlds', () => {
).toEqual([])
})
++ it('makes a new world on starter ground, not an empty map', async () => {
++ const { call } = await setup(2)
++ const id = String((await call('POST', '/api/worlds', 'kim', { title: 'Fresh' })).body.id)
++ const map = (await call('GET', `/api/worlds/${id}/map`, 'kim')).body as {
++ terrains: { id: string }[]
++ }
++ expect(map.terrains.map((t) => t.id)).toEqual(['ground'])
++ })
++
it('lets the owner show a world from above (2D) or in first person; a remix keeps the view', async () => {
const { call } = await setup(2)
const id = String((await call('POST', '/api/worlds', 'kim', { title: 'Maze' })).body.id)
ddiff --git a/apps/server/src/worlds/worldRoutes.ts b/apps/server/src/worlds/worldRoutes.ts
iindex 565223f..87d6b7b 100644
---- a/apps/server/src/worlds/worldRoutes.ts
++++ b/apps/server/src/worlds/worldRoutes.ts
@@@ -6,7 +6,7 @@ import {
CREATOR_WORLD_ID,
creatorMapIssues,
creatorScriptsOf,
-- emptyMapV2,
++ starterCreatorMap,
parseMapFile,
type MapFileV2,
} from '@openvibe/content'
@@@ -333,7 +333,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
ownerName: user.name,
title: title as string,
summary: summary as string,
-- map: emptyMapV2(),
++ map: starterCreatorMap(),
now: now(),
})
opts.log.info('creator world created', { world: world.id })
ddiff --git a/docs/games.md b/docs/games.md
iindex 991a48e..adb41ea 100644
---- a/docs/games.md
++++ b/docs/games.md
@@@ -76,6 +76,10 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
- **Storage:** `creator_worlds` (migration 0010) holds the title, summary, status (`draft`, `published`, `hidden`)
and the editor's v2 map document; `revision` is its hash and the editor's `If-Match`.
++- **Starter ground:** a new world's map is `starterCreatorMap()`: one flat, sculptable grass terrain patch over the whole
++ play area (160 m square in 2 m cells, id `ground`). A map's terrain objects are its ground; a blank map has only the
++ safety floor, 1.5 m under the sea, so worlds made before 2026-10-27 from an empty map put their players in the water
++ until a terrain patch is added in the editor.
- **Editing:** the owner alone, over `PUT /api/worlds/:id/map`, with no collaboration socket (`/editor-ws` stays
staff-only). Creator maps use the built-in materials: no uploaded textures, models or paint masks (they wait for
the Pics safety pipeline), and smaller limits than staff maps (`CREATOR_MAP_LIMITS`: 2 MB, 4,000 statics, 16
@@@ -121,8 +125,9 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
orthographic camera (about 20 m of the world top to bottom, north up), so the world plays like a 2D game. WASD (or
the touch stick) moves along the screen, and the mouse sets the way the character faces, which is what E and the tools
reach (`input/topDownInputSource.ts`). With no mouse, it faces the way it walks. The pointer is never locked, there
-- is no crosshair, and the player's own body is drawn whole. The world, its map, server, physics and rules are the same
-- in both views, and a remix keeps the view.
++ is no crosshair, and the player's own body is drawn whole, on a ring with a notch the way it faces (yours blue,
++ others white; `render/topDownMarkers.ts`). The world, its map, server, physics and rules are the same in both views,
++ and a remix keeps the view.
- **Who joins:** anyone for a published world; the owner for a draft; games staff for any. Staff
(`staff.games.manage` or `staff.moderation.chat`) take a world down with `PATCH {status: "hidden"}`; the owner
cannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event
ddiff --git a/packages/content/src/packs/creatorWorld.test.ts b/packages/content/src/packs/creatorWorld.test.ts
iindex ea228b8..2fea9b9 100644
---- a/packages/content/src/packs/creatorWorld.test.ts
++++ b/packages/content/src/packs/creatorWorld.test.ts
@@@ -9,7 +9,10 @@ import {
creatorPlaceId,
creatorWorldOf,
creatorWorldPack,
++ starterCreatorMap,
++ STARTER_GROUND_ID,
} from './creatorWorld.js'
++import { parseMapFile } from '../mapFileV2.js'
describe('creator worlds', () => {
it('maps world ids to places and back, refusing anything else', () => {
@@@ -31,6 +34,25 @@ describe('creator worlds', () => {
expect(a.world.zones).toEqual([])
})
++ it('starts a new world on flat ground over the whole play area, inside the creator limits', () => {
++ const map = starterCreatorMap()
++ const parsed = parseMapFile(JSON.parse(JSON.stringify(map)))
++ expect(parsed.ok).toBe(true)
++ const bytes = new TextEncoder().encode(JSON.stringify(map)).length
++ expect(creatorMapIssues(map, bytes)).toEqual([])
++ expect(bytes).toBeLessThan(64 * 1024)
++ const ground = map.terrains[0]
++ expect(map.terrains).toHaveLength(1)
++ expect(ground).toMatchObject({ id: STARTER_GROUND_ID, pos: [0, 0, 0], halfExtent: 80, sub: 80 })
++ // Every sample at height 0: the ground the spawn point (y 1.2) falls onto, above the sea.
++ const heights = new Float32Array(
++ Uint8Array.from(atob(ground?.heights ?? ''), (c) => c.charCodeAt(0)).buffer,
++ )
++ expect(heights.length).toBe(81 * 81)
++ expect(heights.every((h) => h === 0)).toBe(true)
++ expect(creatorWorldPack('w0000000001').map?.groundHalfExtent).toBe(ground?.halfExtent)
++ })
++
it('takes a plain map and refuses uploads, paint and oversized maps', () => {
const plain: MapFileV2 = emptyMapV2()
expect(creatorMapIssues(plain, 200)).toEqual([])
ddiff --git a/packages/content/src/packs/creatorWorld.ts b/packages/content/src/packs/creatorWorld.ts
iindex be278ff..7be4024 100644
---- a/packages/content/src/packs/creatorWorld.ts
++++ b/packages/content/src/packs/creatorWorld.ts
@@@ -1,5 +1,5 @@
import type { ContentPackV2 } from '../pack.js'
--import type { MapFileV2 } from '../mapFileV2.js'
++import { blankHeights, emptyMapV2, type MapFileV2 } from '../mapFileV2.js'
import { ScriptModSchema, type ScriptMod } from '../schema/scriptMod.js'
/**
@@@ -41,6 +41,32 @@ export function creatorWorldPack(worldId: string): ContentPackV2 {
}
}
++/** The starter ground's id in a new world's map. */
++export const STARTER_GROUND_ID = 'ground'
++
++/**
++ * A new creator world's map: flat grass over the whole play area (one sculptable terrain patch, 160 m square in 2 m
++ * cells), so there is ground to stand on before anything is built. A map's terrain objects are its ground (a blank map
++ * has only the safety floor, 1.5 m under the sea), and worlds made from emptyMapV2() put their players in the water.
++ */
++export function starterCreatorMap(): MapFileV2 {
++ const sub = 80
++ return {
++ ...emptyMapV2(),
++ terrains: [
++ {
++ id: STARTER_GROUND_ID,
++ name: 'Ground',
++ pos: [0, 0, 0],
++ halfExtent: 80,
++ sub,
++ heights: blankHeights(sub),
++ surface: { base: { tex: 'leafy_grass' } },
++ },
++ ],
++ }
++}
++
/** What a creator world's map may hold. Staff maps keep the schema's own (much larger) limits. */
export const CREATOR_MAP_LIMITS = {
bytes: 2 * 1024 * 1024,