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)
Changes
@@@ -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.05this.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 {@@@ -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 = trueonAction: ((action: InputAction) => void) | null = nullonWheel: ((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. */nnew file mode 100644@@@ -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)++ })++})nnew file mode 100644@@@ -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,++ }++ }++}@@@ -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))++ : switchableconst useTouch = (touch: boolean): void => {const next = touch ? touchSource : keyboardSource-- if (inputSource.current === next) return++ if (switchable.current === next) returnif (touch) {touchPad.yaw = input.yawtouchPad.pitch = input.pitch@@@ -324,7 +350,8 @@ async function start(): Promise<void> {input.yaw = touchPad.yawinput.pitch = touchPad.pitch}-- inputSource.current = next++ switchable.current = next++ if (touch) pointerOffset.known = falsetouchControls.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_LEVELenvironment.underwater = submergedsfx.setUnderwater(submerged)if (submerged !== wasSubmerged) {@@@ -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)@@@ -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 = trueprivate weatherDim = 0private weatherDimTarget = 0private 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_EXP2this.scene.fogDensity = this.weatherHaze * 0.028const fogTint = 0.55 * (0.35 + day * 0.65)@@@ -18,6 +18,8 @@ const BODY_BACK_OFFSET = 0.15export 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 = falseconstructor(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 + proneBackthis.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,})}@@@ -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.32if (this.currentItem === null) {@@@ -218,6 +218,12 @@ export class Hud {private lastHp = 100private 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')@@@ -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', () => {@@@ -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,@@@ -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', {@@@ -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) : nullconst mine = !!world && world.ownerSubject === user.subjectIdif (!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.statusif (status !== 'draft' && status !== 'published' && status !== 'hidden')@@@ -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, texturesand 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 ownercannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event@@@ -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;nnew file mode 100644@@@ -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'));@@@ -15,6 +15,7 @@ export {type CreatorWorld,type CreatorWorldStatus,type CreatorWorldStore,++ type CreatorWorldView,} from './pg/creatorWorlds.js'export {createCreatorAssetStore,@@@ -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)@@@ -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 | nullremixed_from_owner: string | nullremixes: 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 ?? nullconst 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, mapFROM creator_worlds WHERE owner_subject = ${subject} ORDER BY created_at`)}nnew file mode 100644@@@ -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.
@@ -1,3 +1,4 @@
@@ -1,3 +1,4 @@
+import { Camera } from '@babylonjs/core/Cameras/camera.js'
import { UniversalCamera } from '@babylonjs/core/Cameras/universalCamera.js'
import { UniversalCamera } from '@babylonjs/core/Cameras/universalCamera.js'
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
import type { Scene } from '@babylonjs/core/scene.js'
import type { Scene } from '@babylonjs/core/scene.js'
@@ -18,6 +19,8 @@ import type { InputSource } from '../input/inputSource.js'
@@ -18,6 +19,8 @@ import type { InputSource } from '../input/inputSource.js'
import type { ClientState } from '../state/clientState.js'
import type { ClientState } from '../state/clientState.js'
const HALF_PI = Math.PI / 2
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.
* Client-side prediction of the local player.
@@ -51,6 +54,11 @@ export class LocalPlayer {
@@ -51,6 +54,11 @@ export class LocalPlayer {
/** Vertical step-up smoothing: stairs move the FEET instantly but the
/** Vertical step-up smoothing: stairs move the FEET instantly but the
* EYES glide (Source's smoothed stair climb). */
* EYES glide (Source's smoothed stair climb). */
private stepOffset = 0
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. */
/** New room: old prediction commands belong to the previous simulation. */
onTransfer(): void {
onTransfer(): void {
@@ -76,6 +84,7 @@ export class LocalPlayer {
@@ -76,6 +84,7 @@ export class LocalPlayer {
)
)
this.camera.minZ = 0.05
this.camera.minZ = 0.05
this.camera.fov = 1.1
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
// We drive the camera entirely from simulation + mouse; Babylon's own
// camera inputs stay detached.
// camera inputs stay detached.
this.camera.inputs.clear()
this.camera.inputs.clear()
@@ -196,6 +205,21 @@ export class LocalPlayer {
@@ -196,6 +205,21 @@ export class LocalPlayer {
if (this.corr.length() > 1.5) this.corr.setAll(0)
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. */
/** Per-frame: camera follows interpolated predicted position. */
frameUpdate(alpha: number, dt: number): void {
frameUpdate(alpha: number, dt: number): void {
const x = this.prevPos.x + (this.currPos.x - this.prevPos.x) * alpha
const x = this.prevPos.x + (this.currPos.x - this.prevPos.x) * alpha
@@ -207,8 +231,20 @@ export class LocalPlayer {
@@ -207,8 +231,20 @@ export class LocalPlayer {
this.renderPos.set(x + this.corr.x, y + this.corr.y + this.stepOffset, z + this.corr.z)
this.renderPos.set(x + this.corr.x, y + this.corr.y + this.stepOffset, z + this.corr.z)
const targetEye = eyeOffsetFor(this.move.stance)
const targetEye = eyeOffsetFor(this.move.stance)
this.eyeSmooth += (targetEye - this.eyeSmooth) * Math.min(1, dt * 9)
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 {
get viewYaw(): number {
@@ -220,7 +256,7 @@ export class LocalPlayer {
@@ -220,7 +256,7 @@ export class LocalPlayer {
}
}
get eye(): Vector3 {
get eye(): Vector3 {
- return this.camera.position
+ return this.eyePos
}
}
viewDir(out: { x: number; y: number; z: number }): void {
viewDir(out: { x: number; y: number; z: number }): void {
@@ -15,6 +15,8 @@ export class InputTracker {
@@ -15,6 +15,8 @@ export class InputTracker {
private locked = false
private locked = false
/** UI-open state suppresses look/keys feeding the simulation. */
/** UI-open state suppresses look/keys feeding the simulation. */
uiCapture = false
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
onAction: ((action: InputAction) => void) | null = null
onWheel: ((delta: number) => void) | null = null
onWheel: ((delta: number) => void) | null = null
@@ -46,7 +48,7 @@ export class InputTracker {
@@ -46,7 +48,7 @@ export class InputTracker {
canvas.addEventListener('pointerdown', (e) => {
canvas.addEventListener('pointerdown', (e) => {
// Touch never locks the pointer: TouchControls owns touch pointers.
// Touch never locks the pointer: TouchControls owns touch pointers.
if (e.pointerType === 'touch') return
if (e.pointerType === 'touch') return
- if (!this.uiCapture && !this.locked) {
+ if (!this.uiCapture && !this.locked && this.pointerLock) {
canvas.requestPointerLock()
canvas.requestPointerLock()
}
}
})
})
@@ -168,7 +170,7 @@ export class InputTracker {
@@ -168,7 +170,7 @@ export class InputTracker {
/** Re-engage pointer lock (used when closing menus — needs a user gesture). */
/** Re-engage pointer lock (used when closing menus — needs a user gesture). */
requestLock(): void {
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. */
/** Test harness only: headless browsers cannot pointer-lock. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -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)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -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,
+ }
+ }
+}
@@ -26,6 +26,7 @@ import { InteractionController } from './game/interactionController.js'
@@ -26,6 +26,7 @@ import { InteractionController } from './game/interactionController.js'
import { LocalPlayer } from './game/localPlayer.js'
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 { 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'
@@ -95,6 +96,17 @@ async function start(): Promise<void> {
@@ -95,6 +96,17 @@ async function start(): Promise<void> {
...(token ? { headers: { authorization: `Bearer ${token}` } } : {}),
...(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.
// 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.
// A fresh client can join after a staff script install or reload changed the digest.
let content = createContent()
let content = createContent()
@@ -312,10 +324,24 @@ async function start(): Promise<void> {
@@ -312,10 +324,24 @@ async function start(): Promise<void> {
const touchPad = new TouchPad()
const touchPad = new TouchPad()
const touchSource = new TouchInputSource(touchPad)
const touchSource = new TouchInputSource(touchPad)
const touchControls = new TouchControls(uiRoot, canvas, 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 useTouch = (touch: boolean): void => {
const next = touch ? touchSource : keyboardSource
const next = touch ? touchSource : keyboardSource
- if (inputSource.current === next) return
+ if (switchable.current === next) return
if (touch) {
if (touch) {
touchPad.yaw = input.yaw
touchPad.yaw = input.yaw
touchPad.pitch = input.pitch
touchPad.pitch = input.pitch
@@ -324,7 +350,8 @@ async function start(): Promise<void> {
@@ -324,7 +350,8 @@ async function start(): Promise<void> {
input.yaw = touchPad.yaw
input.yaw = touchPad.yaw
input.pitch = touchPad.pitch
input.pitch = touchPad.pitch
}
}
- inputSource.current = next
+ switchable.current = next
+ if (touch) pointerOffset.known = false
touchControls.setActive(touch)
touchControls.setActive(touch)
}
}
useTouch(matchMedia('(pointer: coarse)').matches)
useTouch(matchMedia('(pointer: coarse)').matches)
@@ -369,6 +396,15 @@ async function start(): Promise<void> {
@@ -369,6 +396,15 @@ async function start(): Promise<void> {
}
}
const fpBody = new FirstPersonBody(scene, content, appearance, player, state)
const fpBody = new FirstPersonBody(scene, content, appearance, player, state)
const viewmodel = new Viewmodel(scene, content, player.camera, appearance)
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 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)
@@ -442,7 +478,7 @@ async function start(): Promise<void> {
@@ -442,7 +478,7 @@ async function start(): Promise<void> {
touchControls.setUiCapture(captured)
touchControls.setUiCapture(captured)
if (captured) input.exitLock()
if (captured) input.exitLock()
// Straight back into the game when the menu closes; touch never locks.
// 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) => {
input.onAction = (action) => {
switch (action.kind) {
switch (action.kind) {
@@ -729,7 +765,7 @@ async function start(): Promise<void> {
@@ -729,7 +765,7 @@ async function start(): Promise<void> {
_viewFwdV.set(_viewFwd.x, _viewFwd.y, _viewFwd.z)
_viewFwdV.set(_viewFwd.x, _viewFwd.y, _viewFwd.z)
if (heldPos) {
if (heldPos) {
activeBeams.set(state.myEntityId, {
activeBeams.set(state.myEntityId, {
- from: viewmodel.beamOrigin(),
+ from: topDown ? fpBody.beamOrigin() : viewmodel.beamOrigin(),
to: heldPos,
to: heldPos,
tangent: _viewFwdV,
tangent: _viewFwdV,
latched: true,
latched: true,
@@ -738,7 +774,7 @@ async function start(): Promise<void> {
@@ -738,7 +774,7 @@ async function start(): Promise<void> {
interact.beamTarget(_beamEnd)
interact.beamTarget(_beamEnd)
_beamEndV.set(_beamEnd.x, _beamEnd.y, _beamEnd.z)
_beamEndV.set(_beamEnd.x, _beamEnd.y, _beamEnd.z)
activeBeams.set(state.myEntityId, {
activeBeams.set(state.myEntityId, {
- from: viewmodel.beamOrigin(),
+ from: topDown ? fpBody.beamOrigin() : viewmodel.beamOrigin(),
to: _beamEndV,
to: _beamEndV,
tangent: _viewFwdV,
tangent: _viewFwdV,
latched: false,
latched: false,
@@ -768,7 +804,7 @@ async function start(): Promise<void> {
@@ -768,7 +804,7 @@ async function start(): Promise<void> {
// Underwater: dense teal fog while the camera is submerged (weather
// Underwater: dense teal fog while the camera is submerged (weather
// haze yields via environment.underwater).
// haze yields via environment.underwater).
- const submerged = player.camera.position.y < WATER_LEVEL
+ const submerged = !topDown && player.eye.y < WATER_LEVEL
environment.underwater = submerged
environment.underwater = submerged
sfx.setUnderwater(submerged)
sfx.setUnderwater(submerged)
if (submerged !== wasSubmerged) {
if (submerged !== wasSubmerged) {
@@ -23,6 +23,8 @@ interface World {
@@ -23,6 +23,8 @@ interface World {
remixes?: number
remixes?: number
/** 'editor' on a world of someone else's team (migration 0015). */
/** 'editor' on a world of someone else's team (migration 0015). */
role?: 'owner' | 'editor'
role?: 'owner' | 'editor'
+ /** '3d' (first person) or 'top' (seen from above, a 2D view; migration 0017). */
+ view?: '3d' | 'top'
}
}
interface Mine {
interface Mine {
@@ -126,6 +128,7 @@ function stats(world: World): string {
@@ -126,6 +128,7 @@ function stats(world: World): string {
const parts = [count(world.plays, 'visit', 'visits')]
const parts = [count(world.plays, 'visit', 'visits')]
if (world.online > 0) parts.unshift(`${world.online} playing now`)
if (world.online > 0) parts.unshift(`${world.online} playing now`)
if (world.remixes) parts.push(count(world.remixes, 'remix', 'remixes'))
if (world.remixes) parts.push(count(world.remixes, 'remix', 'remixes'))
+ if (world.view === 'top') parts.push('top-down')
return parts.join(' · ')
return parts.join(' · ')
}
}
@@ -212,6 +215,11 @@ function mineCard(world: World): HTMLElement {
@@ -212,6 +215,11 @@ function mineCard(world: World): HTMLElement {
actions.append(
actions.append(
button('Scripts', () => void openScripts(world)),
button('Scripts', () => void openScripts(world)),
button('Team', () => void openTeam(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) => {
button('Rename', (b) => {
const title = prompt('New title (up to 60 characters)', world.title)
const title = prompt('New title (up to 60 characters)', world.title)
if (title !== null && title.trim() && title !== world.title)
if (title !== null && title.trim() && title !== world.title)
@@ -194,6 +194,8 @@ export class Environment {
@@ -194,6 +194,8 @@ export class Environment {
private ownsFog = false
private ownsFog = false
/** Set by main each frame; underwater fog wins over weather haze. */
/** Set by main each frame; underwater fog wins over weather haze. */
underwater = false
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 weatherDim = 0
private weatherDimTarget = 0
private weatherDimTarget = 0
private weatherHaze = 0
private weatherHaze = 0
@@ -276,7 +278,7 @@ export class Environment {
@@ -276,7 +278,7 @@ export class Environment {
// fog (main.ts) always wins while submerged.
// fog (main.ts) always wins while submerged.
if (this.underwater) {
if (this.underwater) {
this.ownsFog = false
this.ownsFog = false
- } else if (this.weatherHaze > 0.02) {
+ } else if (this.weatherHaze > 0.02 && this.hazeFog) {
this.scene.fogMode = FOGMODE_EXP2
this.scene.fogMode = FOGMODE_EXP2
this.scene.fogDensity = this.weatherHaze * 0.028
this.scene.fogDensity = this.weatherHaze * 0.028
const fogTint = 0.55 * (0.35 + day * 0.65)
const fogTint = 0.55 * (0.35 + day * 0.65)
@@ -18,6 +18,8 @@ const BODY_BACK_OFFSET = 0.15
@@ -18,6 +18,8 @@ const BODY_BACK_OFFSET = 0.15
export class FirstPersonBody {
export class FirstPersonBody {
private readonly avatar: Avatar
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(
constructor(
scene: Scene,
scene: Scene,
@@ -35,8 +37,15 @@ export class FirstPersonBody {
@@ -35,8 +37,15 @@ export class FirstPersonBody {
setAppearance(appearance: Appearance): void {
setAppearance(appearance: Appearance): void {
this.avatar.setAppearance(appearance)
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 {
triggerSwing(): void {
@@ -65,7 +74,9 @@ export class FirstPersonBody {
@@ -65,7 +74,9 @@ export class FirstPersonBody {
// Prone pitches the torso forward from the hips — slide the whole body
// Prone pitches the torso forward from the hips — slide the whole body
// back so the chest doesn't engulf the camera.
// back so the chest doesn't engulf the camera.
const proneBack = move.stance === 2 ? 1.15 : 0
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({
this.avatar.update({
dt,
dt,
time,
time,
@@ -79,7 +90,7 @@ export class FirstPersonBody {
@@ -79,7 +90,7 @@ export class FirstPersonBody {
grounded: move.grounded,
grounded: move.grounded,
// The screen-space viewmodel represents the tool in first person; a
// 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.
// 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,
beamActive,
})
})
}
}
@@ -276,6 +276,11 @@ export class Viewmodel {
@@ -276,6 +276,11 @@ export class Viewmodel {
for (const m of this.physgunMeshes) m.isVisible = visible
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 {
triggerSwing(): void {
this.swingT = 0.32
this.swingT = 0.32
if (this.currentItem === null) {
if (this.currentItem === null) {
@@ -218,6 +218,12 @@ export class Hud {
@@ -218,6 +218,12 @@ export class Hud {
private lastHp = 100
private lastHp = 100
private hitmarkerTimer: ReturnType<typeof setTimeout> | null = null
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. */
/** Brief crosshair X when a melee hit lands on another player. */
flashHitmarker(): void {
flashHitmarker(): void {
const el = this.byId('hitmarker')
const el = this.byId('hitmarker')
@@ -58,6 +58,7 @@ const world = (id: string, plays: number, publishedAt: number): CreatorWorld =>
@@ -58,6 +58,7 @@ const world = (id: string, plays: number, publishedAt: number): CreatorWorld =>
publishedAt,
publishedAt,
remixedFrom: null,
remixedFrom: null,
remixes: 0,
remixes: 0,
+ view: '3d',
})
})
describe('the home page live sections', () => {
describe('the home page live sections', () => {
@@ -130,6 +130,7 @@ describe('creator worlds in OpenVibe.Search', () => {
@@ -130,6 +130,7 @@ describe('creator worlds in OpenVibe.Search', () => {
publishedAt: 2,
publishedAt: 2,
remixedFrom: null,
remixedFrom: null,
remixes: 0,
remixes: 0,
+ view: '3d',
},
},
'https://openvibe.games',
'https://openvibe.games',
shell,
shell,
@@ -157,6 +158,7 @@ describe('creator worlds in OpenVibe.Search', () => {
@@ -157,6 +158,7 @@ describe('creator worlds in OpenVibe.Search', () => {
publishedAt: 2,
publishedAt: 2,
remixedFrom: { id: 'wk3j9x0a2bq', title: 'Moon <script>', owner: 'kim <b>' },
remixedFrom: { id: 'wk3j9x0a2bq', title: 'Moon <script>', owner: 'kim <b>' },
remixes: 2,
remixes: 2,
+ view: '3d',
},
},
'https://openvibe.games',
'https://openvibe.games',
shell,
shell,
@@ -252,6 +252,24 @@ describe('/api/worlds', () => {
@@ -252,6 +252,24 @@ describe('/api/worlds', () => {
).toEqual([])
).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 () => {
it("remixes a published world into a new draft of the remixer's own, credited and counted", async () => {
const { call } = await setup(2)
const { call } = await setup(2)
const made = await call('POST', '/api/worlds', 'kim', {
const made = await call('POST', '/api/worlds', 'kim', {
@@ -10,7 +10,12 @@ import {
@@ -10,7 +10,12 @@ import {
parseMapFile,
parseMapFile,
type MapFileV2,
type MapFileV2,
} from '@openvibe/content'
} 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 { Logger } from '@openvibe/shared'
import type { Db, Tx } from 'openvibe-sdk/db'
import type { Db, Tx } from 'openvibe-sdk/db'
import type { EventInput } from 'openvibe-sdk/events'
import type { EventInput } from 'openvibe-sdk/events'
@@ -35,7 +40,7 @@ import type { WorldSearchIndex } from './searchIndex.js'
@@ -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 one world (published, or the caller's, or staff)
* GET /api/worlds/:id/map its map document, ETag = revision
* 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
* 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
* DELETE /api/worlds/:id the owner or games staff
*/
*/
@@ -145,6 +150,8 @@ function publicView(world: CreatorWorld, online: number) {
@@ -145,6 +150,8 @@ function publicView(world: CreatorWorld, online: number) {
}
}
: null,
: null,
remixes: world.remixes,
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) {
@@ -359,6 +366,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
map: (stored as { map: unknown }).map,
map: (stored as { map: unknown }).map,
now: now(),
now: now(),
remixedFrom: { id: src.id, title: src.title, owner: src.ownerName },
remixedFrom: { id: src.id, title: src.title, owner: src.ownerName },
+ view: src.view,
})
})
if (!own) await opts.store.countRemix(src.id)
if (!own) await opts.store.countRemix(src.id)
opts.log.info('creator world remixed', { world: world.id, from: src.id })
opts.log.info('creator world remixed', { world: world.id, from: src.id })
@@ -556,7 +564,12 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -556,7 +564,12 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
const mine = !!world && world.ownerSubject === user.subjectId
const mine = !!world && world.ownerSubject === user.subjectId
if (!world || (!mine && !isStaff(user))) return fail(404, 'world_not_found')
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 (body?.title !== undefined) {
if (!mine) fail(403, 'owner_only')
if (!mine) fail(403, 'owner_only')
const title = cleanText(body.title, TITLE_MAX)
const title = cleanText(body.title, TITLE_MAX)
@@ -571,6 +584,12 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -571,6 +584,12 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
fail(422, 'invalid_summary', { detail: `At most ${SUMMARY_MAX} characters.` })
fail(422, 'invalid_summary', { detail: `At most ${SUMMARY_MAX} characters.` })
patch.summary = summary as string
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) {
if (body?.status !== undefined) {
const status = body.status
const status = body.status
if (status !== 'draft' && status !== 'published' && status !== 'hidden')
if (status !== 'draft' && status !== 'published' && status !== 'hidden')
@@ -116,6 +116,13 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
@@ -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
(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
`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.
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
- **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
@@ -70,6 +70,9 @@ Already here:
@@ -70,6 +70,9 @@ Already here:
- Uploaded models and textures wait on the Pics safety pipeline, as the editor's limits already note.
- 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.
- 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.
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:
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);
- campaign landing links with attribution (plan T22);
- a first load kept under about 8 MB for 3D and the casual budget for 2D;
- a first load kept under about 8 MB for 3D and the casual budget for 2D;
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -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'));
@@ -15,6 +15,7 @@ export {
@@ -15,6 +15,7 @@ export {
type CreatorWorld,
type CreatorWorld,
type CreatorWorldStatus,
type CreatorWorldStatus,
type CreatorWorldStore,
type CreatorWorldStore,
+ type CreatorWorldView,
} from './pg/creatorWorlds.js'
} from './pg/creatorWorlds.js'
export {
export {
createCreatorAssetStore,
createCreatorAssetStore,
@@ -51,6 +51,21 @@ describe('creator worlds store', () => {
@@ -51,6 +51,21 @@ describe('creator worlds store', () => {
remixedFrom: { id: world.id, title: 'Moon base', owner: 'kim' },
remixedFrom: { id: world.id, title: 'Moon base', owner: 'kim' },
})
})
expect(remix.remixedFrom).toEqual({ 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)
await store.countRemix(world.id)
expect((await store.get(world.id))?.remixes).toBe(1)
expect((await store.get(world.id))?.remixes).toBe(1)
await store.remove(remix.id)
await store.remove(remix.id)
@@ -10,6 +10,8 @@ import { idList } from './sqlUtils.js'
@@ -10,6 +10,8 @@ import { idList } from './sqlUtils.js'
*/
*/
export type CreatorWorldStatus = 'draft' | 'published' | 'hidden'
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 {
export interface CreatorWorld {
id: string
id: string
@@ -27,6 +29,7 @@ export interface CreatorWorld {
@@ -27,6 +29,7 @@ export interface CreatorWorld {
remixedFrom: { id: string; title: string; owner: string } | null
remixedFrom: { id: string; title: string; owner: string } | null
/** How often other people remixed this world. */
/** How often other people remixed this world. */
remixes: number
remixes: number
+ view: CreatorWorldView
}
}
interface Row {
interface Row {
@@ -45,9 +48,10 @@ interface Row {
@@ -45,9 +48,10 @@ interface Row {
remixed_from_title: string | null
remixed_from_title: string | null
remixed_from_owner: string | null
remixed_from_owner: string | null
remixes: number | 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 {
function fromRow(row: Row): CreatorWorld {
return {
return {
@@ -70,6 +74,7 @@ function fromRow(row: Row): CreatorWorld {
@@ -70,6 +74,7 @@ function fromRow(row: Row): CreatorWorld {
}
}
: null,
: null,
remixes: Number(row.remixes ?? 0),
remixes: Number(row.remixes ?? 0),
+ view: row.view === 'top' ? 'top' : '3d',
}
}
}
}
@@ -109,6 +114,7 @@ export interface CreatorWorldStore {
@@ -109,6 +114,7 @@ export interface CreatorWorldStore {
now: number
now: number
/** A remix: the world it copies, as it is now (kept as the credit). */
/** A remix: the world it copies, as it is now (kept as the credit). */
remixedFrom?: { id: string; title: string; owner: string }
remixedFrom?: { id: string; title: string; owner: string }
+ view?: CreatorWorldView
}): Promise<CreatorWorld>
}): Promise<CreatorWorld>
get(id: string): Promise<CreatorWorld | null>
get(id: string): Promise<CreatorWorld | null>
/** The stored map document and its revision. */
/** The stored map document and its revision. */
@@ -133,7 +139,12 @@ export interface CreatorWorldStore {
@@ -133,7 +139,12 @@ export interface CreatorWorldStore {
): Promise<CreatorWorld | 'conflict' | null>
): Promise<CreatorWorld | 'conflict' | null>
update(
update(
id: string,
id: string,
- patch: { title?: string; summary?: string; status?: CreatorWorldStatus },
+ patch: {
+ title?: string
+ summary?: string
+ status?: CreatorWorldStatus
+ view?: CreatorWorldView
+ },
now: number,
now: number,
): Promise<CreatorWorld | null>
): Promise<CreatorWorld | null>
/** The owner's display name follows the Network's (set at each signed-in write). */
/** 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 {
@@ -166,10 +177,10 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {
const from = input.remixedFrom ?? null
const from = input.remixedFrom ?? null
const row = await db.one<Row>(sql`
const row = await db.one<Row>(sql`
INSERT INTO creator_worlds (id, owner_subject, owner_name, title, summary, map, revision, created_at, updated_at,
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},
VALUES (${id}, ${input.ownerSubject}, ${input.ownerName}, ${input.title}, ${input.summary},
${sql.json(input.map)}, ${mapRevision(input.map)}, ${input.now}, ${input.now},
${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}
RETURNING ${COLUMNS}
`)
`)
return fromRow(row)
return fromRow(row)
@@ -235,6 +246,7 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {
@@ -235,6 +246,7 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {
const sets = [sql`updated_at = ${now}`]
const sets = [sql`updated_at = ${now}`]
if (patch.title !== undefined) sets.push(sql`title = ${patch.title}`)
if (patch.title !== undefined) sets.push(sql`title = ${patch.title}`)
if (patch.summary !== undefined) sets.push(sql`summary = ${patch.summary}`)
if (patch.summary !== undefined) sets.push(sql`summary = ${patch.summary}`)
+ if (patch.view !== undefined) sets.push(sql`view = ${patch.view}`)
if (patch.status !== undefined) {
if (patch.status !== undefined) {
sets.push(sql`status = ${patch.status}`)
sets.push(sql`status = ${patch.status}`)
// The first publication dates the listing; unpublishing and publishing again keeps the date.
// The first publication dates the listing; unpublishing and publishing again keeps the date.
@@ -342,7 +354,7 @@ export async function exportCreatorWorlds(
@@ -342,7 +354,7 @@ export async function exportCreatorWorlds(
subject: string,
subject: string,
): Promise<Record<string, unknown>[]> {
): Promise<Record<string, unknown>[]> {
return db.many<Record<string, unknown>>(sql`
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
FROM creator_worlds WHERE owner_subject = ${subject} ORDER BY created_at
`)
`)
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.