openvibers/openvibe.games Commits

Top-down creator worlds (2D): an owner shows a world straight down on the player with an orthographic camera; WASD moves along the screen, the mouse aims, no pointer lock or crosshair, the body drawn whole; same map, server and rules; remixes keep the view (migration 0017) (#165)

OP OpenVibers committed 17 days ago · d7df573e42 · 21 files, +351 −25

Changes

ddiff --git a/apps/client/src/game/localPlayer.ts b/apps/client/src/game/localPlayer.ts
iindex 2ce91a7..17d056c 100644
---- a/apps/client/src/game/localPlayer.ts
++++ b/apps/client/src/game/localPlayer.ts
@@@ -1,3 +1,4 @@
++import { Camera } from '@babylonjs/core/Cameras/camera.js'
import { UniversalCamera } from '@babylonjs/core/Cameras/universalCamera.js'
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
import type { Scene } from '@babylonjs/core/scene.js'
@@@ -18,6 +19,8 @@ import type { InputSource } from '../input/inputSource.js'
import type { ClientState } from '../state/clientState.js'
const HALF_PI = Math.PI / 2
++/** How far above the player a top-down camera sits (it is orthographic, so this only has to clear the scenery). */
++const TOP_DOWN_ABOVE = 120
/**
* Client-side prediction of the local player.
@@@ -51,6 +54,11 @@ export class LocalPlayer {
/** Vertical step-up smoothing: stairs move the FEET instantly but the
* EYES glide (Source's smoothed stair climb). */
private stepOffset = 0
++ /** Where the eyes are: the aim ray's origin, kept apart from the camera because a top-down camera is far above. */
++ private readonly eyePos = new Vector3()
++ /** Metres of world shown top to bottom when seen from above (a creator world's `view: 'top'`); null in first person. */
++ private topDown: number | null = null
++ private readonly firstPersonClip: { minZ: number; maxZ: number }
/** New room: old prediction commands belong to the previous simulation. */
onTransfer(): void {
@@@ -76,6 +84,7 @@ export class LocalPlayer {
)
this.camera.minZ = 0.05
this.camera.fov = 1.1
++ this.firstPersonClip = { minZ: this.camera.minZ, maxZ: this.camera.maxZ }
// We drive the camera entirely from simulation + mouse; Babylon's own
// camera inputs stay detached.
this.camera.inputs.clear()
@@@ -196,6 +205,21 @@ export class LocalPlayer {
if (this.corr.length() > 1.5) this.corr.setAll(0)
}
++ /**
++ * Sees the world straight down on the player, north up, without perspective: a map-like 2D view of the same world
++ * (`height` metres from the top of the screen to the bottom), or first person again with null.
++ */
++ setTopDown(height: number | null): void {
++ this.topDown = height
++ this.camera.mode = height === null ? Camera.PERSPECTIVE_CAMERA : Camera.ORTHOGRAPHIC_CAMERA
++ this.camera.minZ = height === null ? this.firstPersonClip.minZ : 1
++ this.camera.maxZ = height === null ? this.firstPersonClip.maxZ : TOP_DOWN_ABOVE * 2
++ }
++
++ get isTopDown(): boolean {
++ return this.topDown !== null
++ }
++
/** Per-frame: camera follows interpolated predicted position. */
frameUpdate(alpha: number, dt: number): void {
const x = this.prevPos.x + (this.currPos.x - this.prevPos.x) * alpha
@@@ -207,8 +231,20 @@ export class LocalPlayer {
this.renderPos.set(x + this.corr.x, y + this.corr.y + this.stepOffset, z + this.corr.z)
const targetEye = eyeOffsetFor(this.move.stance)
this.eyeSmooth += (targetEye - this.eyeSmooth) * Math.min(1, dt * 9)
-- this.camera.position.set(this.renderPos.x, this.renderPos.y + this.eyeSmooth, this.renderPos.z)
-- this.camera.rotation.set(-this.input.pitch, this.input.yaw, 0)
++ this.eyePos.set(this.renderPos.x, this.renderPos.y + this.eyeSmooth, this.renderPos.z)
++ if (this.topDown === null) {
++ this.camera.position.copyFrom(this.eyePos)
++ this.camera.rotation.set(-this.input.pitch, this.input.yaw, 0)
++ return
++ }
++ const half = this.topDown / 2
++ const aspect = this.camera.getEngine().getAspectRatio(this.camera)
++ this.camera.orthoTop = half
++ this.camera.orthoBottom = -half
++ this.camera.orthoLeft = -half * aspect
++ this.camera.orthoRight = half * aspect
++ this.camera.position.set(this.renderPos.x, this.renderPos.y + TOP_DOWN_ABOVE, this.renderPos.z)
++ this.camera.rotation.set(HALF_PI, 0, 0)
}
get viewYaw(): number {
@@@ -220,7 +256,7 @@ export class LocalPlayer {
}
get eye(): Vector3 {
-- return this.camera.position
++ return this.eyePos
}
viewDir(out: { x: number; y: number; z: number }): void {
ddiff --git a/apps/client/src/input/inputTracker.ts b/apps/client/src/input/inputTracker.ts
iindex bbc4bf7..84e5deb 100644
---- a/apps/client/src/input/inputTracker.ts
++++ b/apps/client/src/input/inputTracker.ts
@@@ -15,6 +15,8 @@ export class InputTracker {
private locked = false
/** UI-open state suppresses look/keys feeding the simulation. */
uiCapture = false
++ /** False for a world seen from above: the pointer stays visible and aims instead of turning the view. */
++ pointerLock = true
onAction: ((action: InputAction) => void) | null = null
onWheel: ((delta: number) => void) | null = null
@@@ -46,7 +48,7 @@ export class InputTracker {
canvas.addEventListener('pointerdown', (e) => {
// Touch never locks the pointer: TouchControls owns touch pointers.
if (e.pointerType === 'touch') return
-- if (!this.uiCapture && !this.locked) {
++ if (!this.uiCapture && !this.locked && this.pointerLock) {
canvas.requestPointerLock()
}
})
@@@ -168,7 +170,7 @@ export class InputTracker {
/** Re-engage pointer lock (used when closing menus — needs a user gesture). */
requestLock(): void {
-- if (!this.locked && !this.uiCapture) this.canvas.requestPointerLock()
++ if (!this.locked && !this.uiCapture && this.pointerLock) this.canvas.requestPointerLock()
}
/** Test harness only: headless browsers cannot pointer-lock. */
ddiff --git a/apps/client/src/input/topDownInputSource.test.ts b/apps/client/src/input/topDownInputSource.test.ts
nnew file mode 100644
iindex 0000000..78f8298
---- /dev/null
++++ b/apps/client/src/input/topDownInputSource.test.ts
@@@ -0,0 +1,73 @@
++import { describe, expect, it } from 'vitest'
++import type { InputSample, InputSource } from './inputSource.js'
++import { TOP_DOWN_AIM_PITCH, TopDownInputSource } from './topDownInputSource.js'
++
++/** The simulation's wish direction (gameplay simulate.ts) for a sample: where the server moves the player. */
++function wish(s: InputSample): { x: number; z: number } {
++ const sy = Math.sin(s.yaw)
++ const cy = Math.cos(s.yaw)
++ return { x: cy * s.moveX + sy * s.moveZ, z: -sy * s.moveX + cy * s.moveZ }
++}
++
++class Keys implements InputSource {
++ moveX = 0
++ moveZ = 0
++ intents = 0
++ readonly yaw = 1.234
++ readonly pitch = 0.4
++ sample(): InputSample {
++ return {
++ moveX: this.moveX,
++ moveZ: this.moveZ,
++ yaw: this.yaw,
++ pitch: this.pitch,
++ intents: this.intents,
++ }
++ }
++}
++
++const close = (a: { x: number; z: number }, b: { x: number; z: number }) => {
++ expect(a.x).toBeCloseTo(b.x, 6)
++ expect(a.z).toBeCloseTo(b.z, 6)
++}
++
++describe('top-down controls', () => {
++ it('moves along the screen whichever way the character faces the mouse', () => {
++ const keys = new Keys()
++ let pointer: { x: number; y: number } | null = { x: 100, y: 0 } // to the right of the player
++ const source = new TopDownInputSource(keys, () => pointer)
++ keys.moveZ = 1 // W: up the screen, north (+Z)
++ let s = source.sample()
++ expect(s.yaw).toBeCloseTo(Math.PI / 2, 6) // facing right (+X)
++ close(wish(s), { x: 0, z: 1 })
++ keys.moveZ = 0
++ keys.moveX = -1 // A: left of the screen (-X)
++ pointer = { x: 0, y: 80 } // below the player: facing south
++ s = source.sample()
++ expect(s.yaw).toBeCloseTo(Math.PI, 6)
++ close(wish(s), { x: -1, z: 0 })
++ expect(s.pitch).toBe(TOP_DOWN_AIM_PITCH)
++ })
++
++ it('faces the way it walks without a pointer, keeps facing when still, and passes intents through', () => {
++ const keys = new Keys()
++ const source = new TopDownInputSource(keys, () => null)
++ keys.moveX = 1
++ keys.intents = 5
++ let s = source.sample()
++ expect(s.yaw).toBeCloseTo(Math.PI / 2, 6)
++ close(wish(s), { x: 1, z: 0 })
++ expect(s.intents).toBe(5)
++ keys.moveX = 0
++ s = source.sample()
++ expect(s.yaw).toBeCloseTo(Math.PI / 2, 6)
++ expect(source.yaw).toBeCloseTo(Math.PI / 2, 6)
++ expect(source.pitch).toBe(TOP_DOWN_AIM_PITCH)
++ })
++
++ it('ignores a pointer sitting on the player itself', () => {
++ const keys = new Keys()
++ const source = new TopDownInputSource(keys, () => ({ x: 2, y: -3 }))
++ expect(source.sample().yaw).toBe(0)
++ })
++})
ddiff --git a/apps/client/src/input/topDownInputSource.ts b/apps/client/src/input/topDownInputSource.ts
nnew file mode 100644
iindex 0000000..5f25825
---- /dev/null
++++ b/apps/client/src/input/topDownInputSource.ts
@@@ -0,0 +1,60 @@
++import type { InputSample, InputSource } from './inputSource.js'
++
++/**
++ * Controls for a world seen from above (a creator world's `view: 'top'`, migration 0017): twin-stick style.
++ *
++ * The camera looks straight down with north (+Z) at the top of the screen, so the wrapped source's move axes are read
++ * as SCREEN directions: W is up the screen, D is right, whatever way the character faces. The character faces the
++ * mouse (or, with no mouse, the way it walks), and that facing is the yaw the simulation and the aim ray use. The
++ * move axes are turned back into the simulation's yaw-relative ones (gameplay's simulate.ts wish direction), so the
++ * server moves the player exactly where the screen says. Pitch stays tilted down, so the aim ray finds what is on the
++ * ground just ahead (nodes, props) within reach.
++ */
++export const TOP_DOWN_AIM_PITCH = -0.55
++
++/** Where the pointer is, relative to the player on screen: +x right, +y down (CSS pixels); null when unknown. */
++export type PointerOffset = () => { x: number; y: number } | null
++
++export class TopDownInputSource implements InputSource {
++ private facing = 0
++
++ constructor(
++ private readonly inner: InputSource,
++ private readonly pointer: PointerOffset,
++ ) {}
++
++ get yaw(): number {
++ return this.facing
++ }
++
++ get pitch(): number {
++ return TOP_DOWN_AIM_PITCH
++ }
++
++ /** Screen up is +Z, screen right is +X: a yaw of 0 faces up the screen. */
++ private aim(): void {
++ const p = this.pointer()
++ if (p && Math.hypot(p.x, p.y) > 6) this.facing = Math.atan2(p.x, -p.y)
++ }
++
++ sample(): InputSample {
++ const s = this.inner.sample()
++ // The wrapped axes as a world direction (screen right = +X, screen up = +Z).
++ const wx = s.moveX
++ const wz = s.moveZ
++ const moving = Math.hypot(wx, wz) > 1e-3
++ const before = this.facing
++ this.aim()
++ // No pointer (touch, or it never moved): face the way you walk.
++ if (this.facing === before && moving && !this.pointer()) this.facing = Math.atan2(wx, wz)
++ const sy = Math.sin(this.facing)
++ const cy = Math.cos(this.facing)
++ return {
++ moveX: cy * wx - sy * wz,
++ moveZ: sy * wx + cy * wz,
++ yaw: this.facing,
++ pitch: TOP_DOWN_AIM_PITCH,
++ intents: s.intents,
++ }
++ }
++}
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex a34031f..99ea1a1 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -26,6 +26,7 @@ import { InteractionController } from './game/interactionController.js'
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 { TouchControls } from './input/touchControls.js'
import { TouchInputSource, TouchPad } from './input/touchInputSource.js'
import { Connection, gameSocketUrl, getIdentity, saveName } from './net/connection.js'
@@@ -95,6 +96,17 @@ async function start(): Promise<void> {
...(token ? { headers: { authorization: `Bearer ${token}` } } : {}),
})
}
++ // How a creator world is seen (migration 0017): 'top' looks straight down on the player, a 2D view of the same world.
++ const ssoToken = localStorage.getItem('ovg_sso')
++ const worldView: Promise<'3d' | 'top'> = worldId
++ ? fetch(`/api/worlds/${worldId}`, {
++ cache: 'no-store',
++ ...(ssoToken ? { headers: { authorization: `Bearer ${ssoToken}` } } : {}),
++ })
++ .then((r) => (r.ok ? (r.json() as Promise<{ view?: string }>) : {}))
++ .then((w: { view?: string }) => (w.view === 'top' ? 'top' : '3d'))
++ .catch(() => '3d' as const)
++ : Promise.resolve('3d')
// Fetch the active definition set before constructing game systems or sending hello.
// A fresh client can join after a staff script install or reload changed the digest.
let content = createContent()
@@@ -312,10 +324,24 @@ async function start(): Promise<void> {
const touchPad = new TouchPad()
const touchSource = new TouchInputSource(touchPad)
const touchControls = new TouchControls(uiRoot, canvas, touchPad)
-- const inputSource = new SwitchableInputSource(keyboardSource)
++ const switchable = new SwitchableInputSource(keyboardSource)
++ // From above, WASD (or the stick) moves along the screen and the mouse aims; see topDownInputSource.ts.
++ const topDown = (await worldView) === 'top'
++ const pointerOffset = { x: 0, y: 0, known: false }
++ if (topDown)
++ canvas.addEventListener('pointermove', (e) => {
++ if (e.pointerType === 'touch') return
++ const rect = canvas.getBoundingClientRect()
++ pointerOffset.x = e.clientX - (rect.left + rect.width / 2)
++ pointerOffset.y = e.clientY - (rect.top + rect.height / 2)
++ pointerOffset.known = true
++ })
++ const inputSource = topDown
++ ? new TopDownInputSource(switchable, () => (pointerOffset.known ? pointerOffset : null))
++ : switchable
const useTouch = (touch: boolean): void => {
const next = touch ? touchSource : keyboardSource
-- if (inputSource.current === next) return
++ if (switchable.current === next) return
if (touch) {
touchPad.yaw = input.yaw
touchPad.pitch = input.pitch
@@@ -324,7 +350,8 @@ async function start(): Promise<void> {
input.yaw = touchPad.yaw
input.pitch = touchPad.pitch
}
-- inputSource.current = next
++ switchable.current = next
++ if (touch) pointerOffset.known = false
touchControls.setActive(touch)
}
useTouch(matchMedia('(pointer: coarse)').matches)
@@@ -369,6 +396,15 @@ async function start(): Promise<void> {
}
const fpBody = new FirstPersonBody(scene, content, appearance, player, state)
const viewmodel = new Viewmodel(scene, content, player.camera, appearance)
++ if (topDown) {
++ // About 20 m of the world from the top of the screen to the bottom: the player, what is in reach, and the way on.
++ player.setTopDown(20)
++ input.pointerLock = false
++ fpBody.setThirdPerson(true)
++ viewmodel.setEnabled(false)
++ environment.hazeFog = false
++ hud.setCrosshair(false)
++ }
const beams = new BeamRenderer(scene)
const constraintLines = new ConstraintLineRenderer(scene, state, view)
const ghost = new PlacementGhost(scene, physics, content)
@@@ -442,7 +478,7 @@ async function start(): Promise<void> {
touchControls.setUiCapture(captured)
if (captured) input.exitLock()
// Straight back into the game when the menu closes; touch never locks.
-- else if (inputSource.current === keyboardSource) input.requestLock()
++ else if (switchable.current === keyboardSource) input.requestLock()
}
input.onAction = (action) => {
switch (action.kind) {
@@@ -729,7 +765,7 @@ async function start(): Promise<void> {
_viewFwdV.set(_viewFwd.x, _viewFwd.y, _viewFwd.z)
if (heldPos) {
activeBeams.set(state.myEntityId, {
-- from: viewmodel.beamOrigin(),
++ from: topDown ? fpBody.beamOrigin() : viewmodel.beamOrigin(),
to: heldPos,
tangent: _viewFwdV,
latched: true,
@@@ -738,7 +774,7 @@ async function start(): Promise<void> {
interact.beamTarget(_beamEnd)
_beamEndV.set(_beamEnd.x, _beamEnd.y, _beamEnd.z)
activeBeams.set(state.myEntityId, {
-- from: viewmodel.beamOrigin(),
++ from: topDown ? fpBody.beamOrigin() : viewmodel.beamOrigin(),
to: _beamEndV,
tangent: _viewFwdV,
latched: false,
@@@ -768,7 +804,7 @@ async function start(): Promise<void> {
// Underwater: dense teal fog while the camera is submerged (weather
// haze yields via environment.underwater).
-- const submerged = player.camera.position.y < WATER_LEVEL
++ const submerged = !topDown && player.eye.y < WATER_LEVEL
environment.underwater = submerged
sfx.setUnderwater(submerged)
if (submerged !== wasSubmerged) {
ddiff --git a/apps/client/src/pages/worlds.ts b/apps/client/src/pages/worlds.ts
iindex 5e12b5d..1cb8985 100644
---- a/apps/client/src/pages/worlds.ts
++++ b/apps/client/src/pages/worlds.ts
@@@ -23,6 +23,8 @@ interface World {
remixes?: number
/** 'editor' on a world of someone else's team (migration 0015). */
role?: 'owner' | 'editor'
++ /** '3d' (first person) or 'top' (seen from above, a 2D view; migration 0017). */
++ view?: '3d' | 'top'
}
interface Mine {
@@@ -126,6 +128,7 @@ function stats(world: World): string {
const parts = [count(world.plays, 'visit', 'visits')]
if (world.online > 0) parts.unshift(`${world.online} playing now`)
if (world.remixes) parts.push(count(world.remixes, 'remix', 'remixes'))
++ if (world.view === 'top') parts.push('top-down')
return parts.join(' · ')
}
@@@ -212,6 +215,11 @@ function mineCard(world: World): HTMLElement {
actions.append(
button('Scripts', () => void openScripts(world)),
button('Team', () => void openTeam(world)),
++ // First person, or straight down on the player like a 2D game: same world, map and rules, another camera.
++ button(
++ world.view === 'top' ? 'Switch to first person' : 'Switch to top-down',
++ (b) => void change(b, world.id, { view: world.view === 'top' ? '3d' : 'top' }),
++ ),
button('Rename', (b) => {
const title = prompt('New title (up to 60 characters)', world.title)
if (title !== null && title.trim() && title !== world.title)
ddiff --git a/apps/client/src/render/environment.ts b/apps/client/src/render/environment.ts
iindex e9a003e..8c58053 100644
---- a/apps/client/src/render/environment.ts
++++ b/apps/client/src/render/environment.ts
@@@ -194,6 +194,8 @@ export class Environment {
private ownsFog = false
/** Set by main each frame; underwater fog wins over weather haze. */
underwater = false
++ /** Distance haze; off for a world seen from above, where the camera is far from everything it shows. */
++ hazeFog = true
private weatherDim = 0
private weatherDimTarget = 0
private weatherHaze = 0
@@@ -276,7 +278,7 @@ export class Environment {
// fog (main.ts) always wins while submerged.
if (this.underwater) {
this.ownsFog = false
-- } else if (this.weatherHaze > 0.02) {
++ } else if (this.weatherHaze > 0.02 && this.hazeFog) {
this.scene.fogMode = FOGMODE_EXP2
this.scene.fogDensity = this.weatherHaze * 0.028
const fogTint = 0.55 * (0.35 + day * 0.65)
ddiff --git a/apps/client/src/render/firstPersonBody.ts b/apps/client/src/render/firstPersonBody.ts
iindex 638a062..b352444 100644
---- a/apps/client/src/render/firstPersonBody.ts
++++ b/apps/client/src/render/firstPersonBody.ts
@@@ -18,6 +18,8 @@ const BODY_BACK_OFFSET = 0.15
export class FirstPersonBody {
private readonly avatar: Avatar
++ /** The whole body, head, arms and held tool, where it stands: a world seen from above shows it like anyone else. */
++ private thirdPerson = false
constructor(
scene: Scene,
@@@ -35,8 +37,15 @@ export class FirstPersonBody {
setAppearance(appearance: Appearance): void {
this.avatar.setAppearance(appearance)
-- this.avatar.setHeadVisible(false)
-- this.avatar.setArmsVisible(false)
++ this.avatar.setHeadVisible(this.thirdPerson)
++ this.avatar.setArmsVisible(this.thirdPerson)
++ }
++
++ /** Draws the whole body where the player stands (top-down view), or the first-person body again. */
++ setThirdPerson(on: boolean): void {
++ this.thirdPerson = on
++ this.avatar.setHeadVisible(on)
++ this.avatar.setArmsVisible(on)
}
triggerSwing(): void {
@@@ -65,7 +74,9 @@ export class FirstPersonBody {
// Prone pitches the torso forward from the hips — slide the whole body
// back so the chest doesn't engulf the camera.
const proneBack = move.stance === 2 ? 1.15 : 0
-- const back = BODY_BACK_OFFSET + Math.max(0, -pitch) * 0.12 + speedNorm * 0.06 + proneBack
++ const back = this.thirdPerson
++ ? 0
++ : BODY_BACK_OFFSET + Math.max(0, -pitch) * 0.12 + speedNorm * 0.06 + proneBack
this.avatar.update({
dt,
time,
@@@ -79,7 +90,7 @@ export class FirstPersonBody {
grounded: move.grounded,
// The screen-space viewmodel represents the tool in first person; a
// second copy in the body's hand would wave in front of the camera.
-- itemDef: undefined,
++ itemDef: this.thirdPerson ? (this.state.activeItemDef() ?? undefined) : undefined,
beamActive,
})
}
ddiff --git a/apps/client/src/render/viewmodel.ts b/apps/client/src/render/viewmodel.ts
iindex a0b203d..a0b29c4 100644
---- a/apps/client/src/render/viewmodel.ts
++++ b/apps/client/src/render/viewmodel.ts
@@@ -276,6 +276,11 @@ export class Viewmodel {
for (const m of this.physgunMeshes) m.isVisible = visible
}
++ /** Hidden for a world seen from above: the body holds the tool there. */
++ setEnabled(on: boolean): void {
++ this.rig.setEnabled(on)
++ }
++
triggerSwing(): void {
this.swingT = 0.32
if (this.currentItem === null) {
ddiff --git a/apps/client/src/ui/hud.ts b/apps/client/src/ui/hud.ts
iindex 7010160..b153477 100644
---- a/apps/client/src/ui/hud.ts
++++ b/apps/client/src/ui/hud.ts
@@@ -218,6 +218,12 @@ export class Hud {
private lastHp = 100
private hitmarkerTimer: ReturnType<typeof setTimeout> | null = null
++ /** No crosshair in a world seen from above: the middle of the screen is the player, and the mouse aims. */
++ setCrosshair(visible: boolean): void {
++ const el = this.root.querySelector<HTMLElement>('.crosshair')
++ if (el) el.style.display = visible ? '' : 'none'
++ }
++
/** Brief crosshair X when a melee hit lands on another player. */
flashHitmarker(): void {
const el = this.byId('hitmarker')
ddiff --git a/apps/server/src/net/portalHome.test.ts b/apps/server/src/net/portalHome.test.ts
iindex 322fc1f..a6ab670 100644
---- a/apps/server/src/net/portalHome.test.ts
++++ b/apps/server/src/net/portalHome.test.ts
@@@ -58,6 +58,7 @@ const world = (id: string, plays: number, publishedAt: number): CreatorWorld =>
publishedAt,
remixedFrom: null,
remixes: 0,
++ view: '3d',
})
describe('the home page live sections', () => {
ddiff --git a/apps/server/src/worlds/searchIndex.test.ts b/apps/server/src/worlds/searchIndex.test.ts
iindex 60549bb..4b52e03 100644
---- a/apps/server/src/worlds/searchIndex.test.ts
++++ b/apps/server/src/worlds/searchIndex.test.ts
@@@ -130,6 +130,7 @@ describe('creator worlds in OpenVibe.Search', () => {
publishedAt: 2,
remixedFrom: null,
remixes: 0,
++ view: '3d',
},
'https://openvibe.games',
shell,
@@@ -157,6 +158,7 @@ describe('creator worlds in OpenVibe.Search', () => {
publishedAt: 2,
remixedFrom: { id: 'wk3j9x0a2bq', title: 'Moon <script>', owner: 'kim <b>' },
remixes: 2,
++ view: '3d',
},
'https://openvibe.games',
shell,
ddiff --git a/apps/server/src/worlds/worldRoutes.test.ts b/apps/server/src/worlds/worldRoutes.test.ts
iindex 65ee3e2..0942115 100644
---- a/apps/server/src/worlds/worldRoutes.test.ts
++++ b/apps/server/src/worlds/worldRoutes.test.ts
@@@ -252,6 +252,24 @@ describe('/api/worlds', () => {
).toEqual([])
})
++ 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)
++ expect((await call('GET', `/api/worlds/${id}`, 'kim')).body.view).toBe('3d')
++ expect((await call('PATCH', `/api/worlds/${id}`, 'kim', { view: 'side' })).body.error).toBe(
++ 'invalid_view',
++ )
++ const top = await call('PATCH', `/api/worlds/${id}`, 'kim', { view: 'top' })
++ expect(top.body.view).toBe('top')
++ // Staff take worlds down; they do not change how one is seen.
++ expect((await call('PATCH', `/api/worlds/${id}`, 'staff', { view: '3d' })).status).toBe(403)
++ await call('PATCH', `/api/worlds/${id}`, 'kim', { status: 'published' })
++ expect((await call('GET', `/api/worlds/${id}`)).body.view).toBe('top')
++ const copy = await call('POST', `/api/worlds/${id}/remix`, 'lee')
++ expect(copy.body.view).toBe('top')
++ expect((await call('PATCH', `/api/worlds/${id}`, 'kim', { view: '3d' })).body.view).toBe('3d')
++ })
++
it("remixes a published world into a new draft of the remixer's own, credited and counted", async () => {
const { call } = await setup(2)
const made = await call('POST', '/api/worlds', 'kim', {
ddiff --git a/apps/server/src/worlds/worldRoutes.ts b/apps/server/src/worlds/worldRoutes.ts
iindex 5a4c0d4..565223f 100644
---- a/apps/server/src/worlds/worldRoutes.ts
++++ b/apps/server/src/worlds/worldRoutes.ts
@@@ -10,7 +10,12 @@ import {
parseMapFile,
type MapFileV2,
} from '@openvibe/content'
--import type { CreatorWorld, CreatorWorldStatus, CreatorWorldStore } from '@openvibe/persistence'
++import type {
++ CreatorWorld,
++ CreatorWorldStatus,
++ CreatorWorldStore,
++ CreatorWorldView,
++} from '@openvibe/persistence'
import type { Logger } from '@openvibe/shared'
import type { Db, Tx } from 'openvibe-sdk/db'
import type { EventInput } from 'openvibe-sdk/events'
@@@ -35,7 +40,7 @@ import type { WorldSearchIndex } from './searchIndex.js'
* GET /api/worlds/:id one world (published, or the caller's, or staff)
* GET /api/worlds/:id/map its map document, ETag = revision
* PUT /api/worlds/:id/map the owner saves (If-Match: revision); a running world reloads at once
-- * PATCH /api/worlds/:id { title?, summary?, status? } owner: draft|published; staff: hidden|published
++ * PATCH /api/worlds/:id { title?, summary?, status?, view? } owner: draft|published, 3d|top; staff: hidden|published
* DELETE /api/worlds/:id the owner or games staff
*/
@@@ -145,6 +150,8 @@ function publicView(world: CreatorWorld, online: number) {
}
: null,
remixes: world.remixes,
++ // '3d' (first person) or 'top' (straight down on the player, a 2D view; migration 0017).
++ view: world.view,
}
}
@@@ -359,6 +366,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
map: (stored as { map: unknown }).map,
now: now(),
remixedFrom: { id: src.id, title: src.title, owner: src.ownerName },
++ view: src.view,
})
if (!own) await opts.store.countRemix(src.id)
opts.log.info('creator world remixed', { world: world.id, from: src.id })
@@@ -556,7 +564,12 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
const mine = !!world && world.ownerSubject === user.subjectId
if (!world || (!mine && !isStaff(user))) return fail(404, 'world_not_found')
-- const patch: { title?: string; summary?: string; status?: CreatorWorldStatus } = {}
++ const patch: {
++ title?: string
++ summary?: string
++ status?: CreatorWorldStatus
++ view?: CreatorWorldView
++ } = {}
if (body?.title !== undefined) {
if (!mine) fail(403, 'owner_only')
const title = cleanText(body.title, TITLE_MAX)
@@@ -571,6 +584,12 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
fail(422, 'invalid_summary', { detail: `At most ${SUMMARY_MAX} characters.` })
patch.summary = summary as string
}
++ if (body?.view !== undefined) {
++ if (!mine) fail(403, 'owner_only')
++ if (body.view !== '3d' && body.view !== 'top')
++ fail(422, 'invalid_view', { detail: 'A view is 3d or top.' })
++ patch.view = body.view as CreatorWorldView
++ }
if (body?.status !== undefined) {
const status = body.status
if (status !== 'draft' && status !== 'published' && status !== 'hidden')
ddiff --git a/docs/games.md b/docs/games.md
iindex 94dfeca..991a48e 100644
---- a/docs/games.md
++++ b/docs/games.md
@@@ -116,6 +116,13 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
(worlds keep their copy); staff take one down (`asset.hidden`, `asset.restored`, `asset.deleted` as
`games.moderation.action`). Assets and uses follow account export, merge and deletion. Uploaded models, textures
and sounds wait for the Pics safety pipeline, and paid assets for the owner's prices.
++- **View (2D):** the owner shows a world in first person (`3d`, the default) or **top-down** (`top`, migration 0017;
++ `PATCH /api/worlds/:id {view}`, `/worlds` → **Switch to top-down**). Top-down looks straight down on the player with an
++ 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.
- **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/docs/platform-direction.md b/docs/platform-direction.md
iindex 31f583c..21af4b8 100644
---- a/docs/platform-direction.md
++++ b/docs/platform-direction.md
@@@ -70,6 +70,9 @@ Already here:
- Uploaded models and textures wait on the Pics safety pipeline, as the editor's limits already note.
- Paid assets come later, priced in Vibes through Billing, with items in Inventory (ADR-054). The owner sets the prices.
6. **2D.** The casual kit is the 2D path. Next, a 2D world type made from the same editor and pack format, so a 2D game is a world too.
++ - Started (migration 0017): a world can be **top-down**, seen straight down without perspective, with screen-relative
++ movement and mouse aim; same editor, map, pack and server. Next: sprite looks for pieces and characters, so a
++ top-down world can also look drawn rather than modelled.
7. **The instant-play funnel.** `/play/<game>` already opens with no download and as a guest. To add:
- campaign landing links with attribution (plan T22);
- a first load kept under about 8 MB for 3D and the casual budget for 2D;
ddiff --git a/packages/persistence/migrations/0017_world_view.sql b/packages/persistence/migrations/0017_world_view.sql
nnew file mode 100644
iindex 0000000..dbd3c22
---- /dev/null
++++ b/packages/persistence/migrations/0017_world_view.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- How a creator world is seen (docs/platform-direction.md step 6, 2D): '3d' is the first-person view Scraplandia has;
++-- 'top' looks straight down on the player (an orthographic, map-like 2D view: WASD moves on the screen, the mouse
++-- aims). The world, its map, its server and its rules are the same either way; only the client's camera and controls
++-- change. Remixes keep it.
++ALTER TABLE creator_worlds ADD COLUMN IF NOT EXISTS view text NOT NULL DEFAULT '3d' CHECK (view IN ('3d', 'top'));
ddiff --git a/packages/persistence/src/index.ts b/packages/persistence/src/index.ts
iindex 36c6f43..949f209 100644
---- a/packages/persistence/src/index.ts
++++ b/packages/persistence/src/index.ts
@@@ -15,6 +15,7 @@ export {
type CreatorWorld,
type CreatorWorldStatus,
type CreatorWorldStore,
++ type CreatorWorldView,
} from './pg/creatorWorlds.js'
export {
createCreatorAssetStore,
ddiff --git a/packages/persistence/src/pg/creatorWorlds.test.ts b/packages/persistence/src/pg/creatorWorlds.test.ts
iindex 253365d..308cf90 100644
---- a/packages/persistence/src/pg/creatorWorlds.test.ts
++++ b/packages/persistence/src/pg/creatorWorlds.test.ts
@@@ -51,6 +51,21 @@ describe('creator worlds store', () => {
remixedFrom: { id: world.id, title: 'Moon base', owner: 'kim' },
})
expect(remix.remixedFrom).toEqual({ id: world.id, title: 'Moon base', owner: 'kim' })
++ // Seen in first person unless the owner chooses from above (migration 0017); a remix can carry the view.
++ expect(world.view).toBe('3d')
++ expect((await store.update(world.id, { view: 'top' }, 1002))?.view).toBe('top')
++ const topRemix = await store.create({
++ ownerSubject: KIM,
++ ownerName: 'kim',
++ title: 'Top',
++ summary: '',
++ map: MAP,
++ now: 1003,
++ view: 'top',
++ })
++ expect(topRemix.view).toBe('top')
++ await store.remove(topRemix.id)
++ await store.update(world.id, { view: '3d' }, 1004)
await store.countRemix(world.id)
expect((await store.get(world.id))?.remixes).toBe(1)
await store.remove(remix.id)
ddiff --git a/packages/persistence/src/pg/creatorWorlds.ts b/packages/persistence/src/pg/creatorWorlds.ts
iindex 3d0f33a..e2af467 100644
---- a/packages/persistence/src/pg/creatorWorlds.ts
++++ b/packages/persistence/src/pg/creatorWorlds.ts
@@@ -10,6 +10,8 @@ import { idList } from './sqlUtils.js'
*/
export type CreatorWorldStatus = 'draft' | 'published' | 'hidden'
++/** How the world is seen (migration 0017): first person, or straight down on the player (2D). */
++export type CreatorWorldView = '3d' | 'top'
export interface CreatorWorld {
id: string
@@@ -27,6 +29,7 @@ export interface CreatorWorld {
remixedFrom: { id: string; title: string; owner: string } | null
/** How often other people remixed this world. */
remixes: number
++ view: CreatorWorldView
}
interface Row {
@@@ -45,9 +48,10 @@ interface Row {
remixed_from_title: string | null
remixed_from_owner: string | null
remixes: number | string | null
++ view: CreatorWorldView | null
}
--const COLUMNS = sql`id, owner_subject, owner_name, title, summary, status, revision, plays, created_at, updated_at, published_at, remixed_from, remixed_from_title, remixed_from_owner, remixes`
++const COLUMNS = sql`id, owner_subject, owner_name, title, summary, status, revision, plays, created_at, updated_at, published_at, remixed_from, remixed_from_title, remixed_from_owner, remixes, view`
function fromRow(row: Row): CreatorWorld {
return {
@@@ -70,6 +74,7 @@ function fromRow(row: Row): CreatorWorld {
}
: null,
remixes: Number(row.remixes ?? 0),
++ view: row.view === 'top' ? 'top' : '3d',
}
}
@@@ -109,6 +114,7 @@ export interface CreatorWorldStore {
now: number
/** A remix: the world it copies, as it is now (kept as the credit). */
remixedFrom?: { id: string; title: string; owner: string }
++ view?: CreatorWorldView
}): Promise<CreatorWorld>
get(id: string): Promise<CreatorWorld | null>
/** The stored map document and its revision. */
@@@ -133,7 +139,12 @@ export interface CreatorWorldStore {
): Promise<CreatorWorld | 'conflict' | null>
update(
id: string,
-- patch: { title?: string; summary?: string; status?: CreatorWorldStatus },
++ patch: {
++ title?: string
++ summary?: string
++ status?: CreatorWorldStatus
++ view?: CreatorWorldView
++ },
now: number,
): Promise<CreatorWorld | null>
/** The owner's display name follows the Network's (set at each signed-in write). */
@@@ -166,10 +177,10 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {
const from = input.remixedFrom ?? null
const row = await db.one<Row>(sql`
INSERT INTO creator_worlds (id, owner_subject, owner_name, title, summary, map, revision, created_at, updated_at,
-- remixed_from, remixed_from_title, remixed_from_owner)
++ remixed_from, remixed_from_title, remixed_from_owner, view)
VALUES (${id}, ${input.ownerSubject}, ${input.ownerName}, ${input.title}, ${input.summary},
${sql.json(input.map)}, ${mapRevision(input.map)}, ${input.now}, ${input.now},
-- ${from ? from.id : null}, ${from ? from.title : null}, ${from ? from.owner : null})
++ ${from ? from.id : null}, ${from ? from.title : null}, ${from ? from.owner : null}, ${input.view ?? '3d'})
RETURNING ${COLUMNS}
`)
return fromRow(row)
@@@ -235,6 +246,7 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {
const sets = [sql`updated_at = ${now}`]
if (patch.title !== undefined) sets.push(sql`title = ${patch.title}`)
if (patch.summary !== undefined) sets.push(sql`summary = ${patch.summary}`)
++ if (patch.view !== undefined) sets.push(sql`view = ${patch.view}`)
if (patch.status !== undefined) {
sets.push(sql`status = ${patch.status}`)
// The first publication dates the listing; unpublishing and publishing again keeps the date.
@@@ -342,7 +354,7 @@ export async function exportCreatorWorlds(
subject: string,
): Promise<Record<string, unknown>[]> {
return db.many<Record<string, unknown>>(sql`
-- SELECT id, owner_name, title, summary, status, revision, plays, created_at, updated_at, published_at, map
++ SELECT id, owner_name, title, summary, status, view, revision, plays, created_at, updated_at, published_at, map
FROM creator_worlds WHERE owner_subject = ${subject} ORDER BY created_at
`)
}
ddiff --git a/test/apps__client__src__input__topDownInputSource.test.js b/test/apps__client__src__input__topDownInputSource.test.js
nnew file mode 100644
iindex 0000000..6e3c535
---- /dev/null
++++ b/test/apps__client__src__input__topDownInputSource.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/client/src/input/topDownInputSource.test.ts'
++// covers: 'apps/client/src/input'
++// Generated by node test/run.js --index; do not edit.