openvibers/openvibe.games Commits
New creator worlds start on ground; top-down worlds draw a ring under each player (#167)
Changes
@@@ -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 = falsehud.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) : nullconst 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 dynamicsplayer.frameUpdate(timestep.alpha, elapsed)view.update(now / 1000)++ markers?.update()// First-person presentation: body, viewmodel, beams.const selfHolding = [...state.heldBy.values()].includes(state.myEntityId)@@@ -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 = appearancennew file mode 100644@@@ -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)++ }++ }++}@@@ -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)@@@ -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 })@@@ -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` staysstaff-only). Creator maps use the built-in materials: no uploaded textures, models or paint masks (they wait forthe 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 inorthographic camera (about 20 m of the world top to bottom, north up), so the world plays like a 2D game. WASD (orthe touch stick) moves along the screen, and the mouse sets the way the character faces, which is what E and the toolsreach (`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 ownercannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event@@@ -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([])@@@ -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,
@@ -27,6 +27,7 @@ import { LocalPlayer } from './game/localPlayer.js'
@@ -27,6 +27,7 @@ import { LocalPlayer } from './game/localPlayer.js'
import { InputTracker } from './input/inputTracker.js'
import { InputTracker } from './input/inputTracker.js'
import { KeyboardInputSource, SwitchableInputSource } from './input/inputSource.js'
import { KeyboardInputSource, SwitchableInputSource } from './input/inputSource.js'
import { TopDownInputSource } from './input/topDownInputSource.js'
import { TopDownInputSource } from './input/topDownInputSource.js'
+import { TopDownMarkers } from './render/topDownMarkers.js'
import { TouchControls } from './input/touchControls.js'
import { TouchControls } from './input/touchControls.js'
import { TouchInputSource, TouchPad } from './input/touchInputSource.js'
import { TouchInputSource, TouchPad } from './input/touchInputSource.js'
import { Connection, gameSocketUrl, getIdentity, saveName } from './net/connection.js'
import { Connection, gameSocketUrl, getIdentity, saveName } from './net/connection.js'
@@ -405,6 +406,8 @@ async function start(): Promise<void> {
@@ -405,6 +406,8 @@ async function start(): Promise<void> {
environment.hazeFog = false
environment.hazeFog = false
hud.setCrosshair(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 beams = new BeamRenderer(scene)
const constraintLines = new ConstraintLineRenderer(scene, state, view)
const constraintLines = new ConstraintLineRenderer(scene, state, view)
const ghost = new PlacementGhost(scene, physics, content)
const ghost = new PlacementGhost(scene, physics, content)
@@ -703,6 +706,7 @@ async function start(): Promise<void> {
@@ -703,6 +706,7 @@ async function start(): Promise<void> {
physics.step(0) // query-only world: refresh broadphase, no dynamics
physics.step(0) // query-only world: refresh broadphase, no dynamics
player.frameUpdate(timestep.alpha, elapsed)
player.frameUpdate(timestep.alpha, elapsed)
view.update(now / 1000)
view.update(now / 1000)
+ markers?.update()
// First-person presentation: body, viewmodel, beams.
// First-person presentation: body, viewmodel, beams.
const selfHolding = [...state.heldBy.values()].includes(state.myEntityId)
const selfHolding = [...state.heldBy.values()].includes(state.myEntityId)
@@ -131,6 +131,11 @@ export class Avatar {
@@ -131,6 +131,11 @@ export class Avatar {
return this.rig.joints.root.position
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. */
/** Rebuilds the rig (customization preview edits). Preserves pose state loosely. */
setAppearance(appearance: Appearance): void {
setAppearance(appearance: Appearance): void {
this.appearance = appearance
this.appearance = appearance
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -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)
+ }
+ }
+}
@@ -252,6 +252,15 @@ describe('/api/worlds', () => {
@@ -252,6 +252,15 @@ describe('/api/worlds', () => {
).toEqual([])
).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 () => {
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 { call } = await setup(2)
const id = String((await call('POST', '/api/worlds', 'kim', { title: 'Maze' })).body.id)
const id = String((await call('POST', '/api/worlds', 'kim', { title: 'Maze' })).body.id)
@@ -6,7 +6,7 @@ import {
@@ -6,7 +6,7 @@ import {
CREATOR_WORLD_ID,
CREATOR_WORLD_ID,
creatorMapIssues,
creatorMapIssues,
creatorScriptsOf,
creatorScriptsOf,
- emptyMapV2,
+ starterCreatorMap,
parseMapFile,
parseMapFile,
type MapFileV2,
type MapFileV2,
} from '@openvibe/content'
} from '@openvibe/content'
@@ -333,7 +333,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -333,7 +333,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
ownerName: user.name,
ownerName: user.name,
title: title as string,
title: title as string,
summary: summary as string,
summary: summary as string,
- map: emptyMapV2(),
+ map: starterCreatorMap(),
now: now(),
now: now(),
})
})
opts.log.info('creator world created', { world: world.id })
opts.log.info('creator world created', { world: world.id })
@@ -76,6 +76,10 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
@@ -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`)
- **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`.
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
- **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
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
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
@@ -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
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
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
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
- **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
(`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
cannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event
@@ -9,7 +9,10 @@ import {
@@ -9,7 +9,10 @@ import {
creatorPlaceId,
creatorPlaceId,
creatorWorldOf,
creatorWorldOf,
creatorWorldPack,
creatorWorldPack,
+ starterCreatorMap,
+ STARTER_GROUND_ID,
} from './creatorWorld.js'
} from './creatorWorld.js'
+import { parseMapFile } from '../mapFileV2.js'
describe('creator worlds', () => {
describe('creator worlds', () => {
it('maps world ids to places and back, refusing anything else', () => {
it('maps world ids to places and back, refusing anything else', () => {
@@ -31,6 +34,25 @@ describe('creator worlds', () => {
@@ -31,6 +34,25 @@ describe('creator worlds', () => {
expect(a.world.zones).toEqual([])
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', () => {
it('takes a plain map and refuses uploads, paint and oversized maps', () => {
const plain: MapFileV2 = emptyMapV2()
const plain: MapFileV2 = emptyMapV2()
expect(creatorMapIssues(plain, 200)).toEqual([])
expect(creatorMapIssues(plain, 200)).toEqual([])
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
import type { ContentPackV2 } from '../pack.js'
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'
import { ScriptModSchema, type ScriptMod } from '../schema/scriptMod.js'
/**
/**
@@ -41,6 +41,32 @@ export function creatorWorldPack(worldId: string): ContentPackV2 {
@@ -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. */
/** What a creator world's map may hold. Staff maps keep the schema's own (much larger) limits. */
export const CREATOR_MAP_LIMITS = {
export const CREATOR_MAP_LIMITS = {
bytes: 2 * 1024 * 1024,
bytes: 2 * 1024 * 1024,