openvibers/openvibe.games Commits
Games editor: a creator world shows no upload buttons and no Paint tool (both need uploads) (#93)
Changes
@@@ -119,6 +119,11 @@ export function sanitizeTextureName(raw: string, taken: (name: string) => booleaexport class AssetController {constructor(private readonly opts: AssetControllerOptions) {}++ /** Whether this map takes uploads (a creator world does not, for now). */++ get uploadsAllowed(): boolean {++ return this.opts.assetsUrl !== null++ }++/** POST raw bytes to the content-addressed store. */private async upload(blob: Blob): Promise<UploadedAsset | 'forbidden' | null> {const url = this.opts.assetsUrl === undefined ? '/api/map-assets' : this.opts.assetsUrl@@@ -101,7 +101,10 @@ function defaultEditorName(): string {export async function bootEditor(): Promise<void> {const canvas = document.getElementById('game') as HTMLCanvasElementconst mount = document.getElementById('shell') as HTMLElement-- const shell = buildShell(mount, canvas)++ // The place's map, or a creator world's (/editor?world=<id>: the owner's draft, built-in materials, one editor,++ // no Paint: its masks would be uploads).++ const endpoints = mapEndpoints()++ const shell = buildShell(mount, canvas, endpoints.assets ? [] : ['paint'])const engine = new Engine(canvas, true)const scene = new Scene(engine)@@@ -114,8 +117,6 @@ export async function bootEditor(): Promise<void> {// /map.json is the v2 document, so the editor only ever holds v2 — and a// missing or invalid map is emptyMapV2(), never a fabricated starter// heightfield.-- // The place's map, or a creator world's (/editor?world=<id>: the owner's draft, built-in materials, one editor).-- const endpoints = mapEndpoints()const bootResp = await fetchMap(endpoints)const bootRevision = bootResp.headers.get('etag')?.replace(/"/g, '') ?? ''const bootParsed = parseMapFile(await bootResp.json().catch(() => null))@@@ -100,13 +100,18 @@ const textureOptions = (): { value: string; label: string }[] => []/** Build the workspace into `mount` and return every element the app drives. */--export function buildShell(mount: HTMLElement, canvas: HTMLCanvasElement): ShellElements {++/** `hideTools`: tools a mode cannot use (a creator world has no Paint: its masks would be uploads). */++export function buildShell(++ mount: HTMLElement,++ canvas: HTMLCanvasElement,++ hideTools: readonly Tool[] = [],++): ShellElements {const root = el('div', 'workspace', 'workspace')// ── Toolbar ─────────────────────────────────────────────────────────const toolbar = el('div', 'toolbar')const tools = el('div', 'tools', 'tools')-- for (const [id, icon, title] of TOOLS) {++ for (const [id, icon, title] of TOOLS.filter(([t]) => !hideTools.includes(t))) {const b = el('button', 'tool', `tool-${id}`)b.type = 'button'b.dataset['tool'] = id@@@ -126,22 +126,26 @@ export function createEditorUi(opts: EditorUiOptions): EditorUi {)// ── Dock ────────────────────────────────────────────────────────────-- const assets = new AssetBrowser(shell.dockPanels['assets']!, {-- onUseTexture: (name) => {-- const ids = selection.ids()-- if (ids.length > 0) opts.setProperty(ids, 'tex', name)-- else (document.getElementById('paint-tex') as HTMLSelectElement).value = name++ const assets = new AssetBrowser(++ shell.dockPanels['assets']!,++ {++ onUseTexture: (name) => {++ const ids = selection.ids()++ if (ids.length > 0) opts.setProperty(ids, 'tex', name)++ else (document.getElementById('paint-tex') as HTMLSelectElement).value = name++ },++ // Genuinely arms the placement tool rather than telling the user to go++ // and find it themselves.++ onPlaceModel: (id) => opts.placeModel(id),++ onFindUsages: (ids) => selection.replaceMany(ids),++ onDeleteTexture: (name) => void opts.assets.deleteTexture(name),++ onDeleteModel: (id) => void opts.assets.deleteModel(id),++ onRenameTexture: (name, next) => void opts.assets.renameTexture(name, next),++ onImportTexture: (file) => void opts.assets.importTexture(file),++ onImportModel: (file) => void opts.assets.importModel(file),},-- // Genuinely arms the placement tool rather than telling the user to go-- // and find it themselves.-- onPlaceModel: (id) => opts.placeModel(id),-- onFindUsages: (ids) => selection.replaceMany(ids),-- onDeleteTexture: (name) => void opts.assets.deleteTexture(name),-- onDeleteModel: (id) => void opts.assets.deleteModel(id),-- onRenameTexture: (name, next) => void opts.assets.renameTexture(name, next),-- onImportTexture: (file) => void opts.assets.importTexture(file),-- onImportModel: (file) => void opts.assets.importModel(file),-- })++ opts.assets.uploadsAllowed,++ )const historyPanel = new HistoryPanel(shell.dockPanels['history']!, (steps) => {for (let i = 0; i < Math.abs(steps); i++) history.undo()@@@ -64,6 +64,8 @@ export class AssetBrowser {constructor(private readonly root: HTMLElement,private readonly callbacks: AssetBrowserCallbacks,++ /** false in a creator world: no import (uploads are staff-only there for now). */++ private readonly uploads = true,) {}setState(next: Partial<AssetBrowserState>): void {@@@ -71,21 +73,7 @@ export class AssetBrowser {this.render()}-- private render(): void {-- const { tab, filter } = this.state-- this.root.textContent = ''---- const tabs = document.createElement('div')-- tabs.className = 'asset-tabs'-- for (const name of ['textures', 'models'] as const) {-- const b = document.createElement('button')-- b.type = 'button'-- b.textContent = name-- b.className = tab === name ? 'active' : ''-- b.addEventListener('click', () => this.setState({ tab: name }))-- tabs.append(b)-- }-- // Import lives beside the list it adds to, so the flow is obvious.++ private importControl(tab: 'textures' | 'models'): HTMLLabelElement {const importLabel = document.createElement('label')importLabel.className = 'mini asset-import'importLabel.textContent = tab === 'textures' ? '+ Import texture' : '+ Import GLB'@@@ -102,7 +90,32 @@ export class AssetBrowser {importInput.value = ''})importLabel.append(importInput)-- tabs.append(importLabel)++ return importLabel++ }++++ private render(): void {++ const { tab, filter } = this.state++ this.root.textContent = ''++++ const tabs = document.createElement('div')++ tabs.className = 'asset-tabs'++ for (const name of ['textures', 'models'] as const) {++ const b = document.createElement('button')++ b.type = 'button'++ b.textContent = name++ b.className = tab === name ? 'active' : ''++ b.addEventListener('click', () => this.setState({ tab: name }))++ tabs.append(b)++ }++ // Import lives beside the list it adds to, so the flow is obvious. A creator world says why there is none.++ if (this.uploads) tabs.append(this.importControl(tab))++ else {++ const note = document.createElement('span')++ note.className = 'mini asset-import'++ note.textContent = 'Built-in materials only here'++ note.title = 'Uploaded textures and models stay with staff maps for now.'++ tabs.append(note)++ }this.root.append(tabs)const needle = filter.trim().toLowerCase()
@@ -119,6 +119,11 @@ export function sanitizeTextureName(raw: string, taken: (name: string) => boolea
@@ -119,6 +119,11 @@ export function sanitizeTextureName(raw: string, taken: (name: string) => boolea
export class AssetController {
export class AssetController {
constructor(private readonly opts: AssetControllerOptions) {}
constructor(private readonly opts: AssetControllerOptions) {}
+ /** Whether this map takes uploads (a creator world does not, for now). */
+ get uploadsAllowed(): boolean {
+ return this.opts.assetsUrl !== null
+ }
+
/** POST raw bytes to the content-addressed store. */
/** POST raw bytes to the content-addressed store. */
private async upload(blob: Blob): Promise<UploadedAsset | 'forbidden' | null> {
private async upload(blob: Blob): Promise<UploadedAsset | 'forbidden' | null> {
const url = this.opts.assetsUrl === undefined ? '/api/map-assets' : this.opts.assetsUrl
const url = this.opts.assetsUrl === undefined ? '/api/map-assets' : this.opts.assetsUrl
@@ -101,7 +101,10 @@ function defaultEditorName(): string {
@@ -101,7 +101,10 @@ function defaultEditorName(): string {
export async function bootEditor(): Promise<void> {
export async function bootEditor(): Promise<void> {
const canvas = document.getElementById('game') as HTMLCanvasElement
const canvas = document.getElementById('game') as HTMLCanvasElement
const mount = document.getElementById('shell') as HTMLElement
const mount = document.getElementById('shell') as HTMLElement
- const shell = buildShell(mount, canvas)
+ // The place's map, or a creator world's (/editor?world=<id>: the owner's draft, built-in materials, one editor,
+ // no Paint: its masks would be uploads).
+ const endpoints = mapEndpoints()
+ const shell = buildShell(mount, canvas, endpoints.assets ? [] : ['paint'])
const engine = new Engine(canvas, true)
const engine = new Engine(canvas, true)
const scene = new Scene(engine)
const scene = new Scene(engine)
@@ -114,8 +117,6 @@ export async function bootEditor(): Promise<void> {
@@ -114,8 +117,6 @@ export async function bootEditor(): Promise<void> {
// /map.json is the v2 document, so the editor only ever holds v2 — and a
// /map.json is the v2 document, so the editor only ever holds v2 — and a
// missing or invalid map is emptyMapV2(), never a fabricated starter
// missing or invalid map is emptyMapV2(), never a fabricated starter
// heightfield.
// heightfield.
- // The place's map, or a creator world's (/editor?world=<id>: the owner's draft, built-in materials, one editor).
- const endpoints = mapEndpoints()
const bootResp = await fetchMap(endpoints)
const bootResp = await fetchMap(endpoints)
const bootRevision = bootResp.headers.get('etag')?.replace(/"/g, '') ?? ''
const bootRevision = bootResp.headers.get('etag')?.replace(/"/g, '') ?? ''
const bootParsed = parseMapFile(await bootResp.json().catch(() => null))
const bootParsed = parseMapFile(await bootResp.json().catch(() => null))
@@ -100,13 +100,18 @@ const textureOptions = (): { value: string; label: string }[] => [
@@ -100,13 +100,18 @@ const textureOptions = (): { value: string; label: string }[] => [
]
]
/** Build the workspace into `mount` and return every element the app drives. */
/** Build the workspace into `mount` and return every element the app drives. */
-export function buildShell(mount: HTMLElement, canvas: HTMLCanvasElement): ShellElements {
+/** `hideTools`: tools a mode cannot use (a creator world has no Paint: its masks would be uploads). */
+export function buildShell(
+ mount: HTMLElement,
+ canvas: HTMLCanvasElement,
+ hideTools: readonly Tool[] = [],
+): ShellElements {
const root = el('div', 'workspace', 'workspace')
const root = el('div', 'workspace', 'workspace')
// ── Toolbar ─────────────────────────────────────────────────────────
// ── Toolbar ─────────────────────────────────────────────────────────
const toolbar = el('div', 'toolbar')
const toolbar = el('div', 'toolbar')
const tools = el('div', 'tools', 'tools')
const tools = el('div', 'tools', 'tools')
- for (const [id, icon, title] of TOOLS) {
+ for (const [id, icon, title] of TOOLS.filter(([t]) => !hideTools.includes(t))) {
const b = el('button', 'tool', `tool-${id}`)
const b = el('button', 'tool', `tool-${id}`)
b.type = 'button'
b.type = 'button'
b.dataset['tool'] = id
b.dataset['tool'] = id
@@ -126,22 +126,26 @@ export function createEditorUi(opts: EditorUiOptions): EditorUi {
@@ -126,22 +126,26 @@ export function createEditorUi(opts: EditorUiOptions): EditorUi {
)
)
// ── Dock ────────────────────────────────────────────────────────────
// ── Dock ────────────────────────────────────────────────────────────
- const assets = new AssetBrowser(shell.dockPanels['assets']!, {
- onUseTexture: (name) => {
- const ids = selection.ids()
- if (ids.length > 0) opts.setProperty(ids, 'tex', name)
- else (document.getElementById('paint-tex') as HTMLSelectElement).value = name
+ const assets = new AssetBrowser(
+ shell.dockPanels['assets']!,
+ {
+ onUseTexture: (name) => {
+ const ids = selection.ids()
+ if (ids.length > 0) opts.setProperty(ids, 'tex', name)
+ else (document.getElementById('paint-tex') as HTMLSelectElement).value = name
+ },
+ // Genuinely arms the placement tool rather than telling the user to go
+ // and find it themselves.
+ onPlaceModel: (id) => opts.placeModel(id),
+ onFindUsages: (ids) => selection.replaceMany(ids),
+ onDeleteTexture: (name) => void opts.assets.deleteTexture(name),
+ onDeleteModel: (id) => void opts.assets.deleteModel(id),
+ onRenameTexture: (name, next) => void opts.assets.renameTexture(name, next),
+ onImportTexture: (file) => void opts.assets.importTexture(file),
+ onImportModel: (file) => void opts.assets.importModel(file),
},
},
- // Genuinely arms the placement tool rather than telling the user to go
- // and find it themselves.
- onPlaceModel: (id) => opts.placeModel(id),
- onFindUsages: (ids) => selection.replaceMany(ids),
- onDeleteTexture: (name) => void opts.assets.deleteTexture(name),
- onDeleteModel: (id) => void opts.assets.deleteModel(id),
- onRenameTexture: (name, next) => void opts.assets.renameTexture(name, next),
- onImportTexture: (file) => void opts.assets.importTexture(file),
- onImportModel: (file) => void opts.assets.importModel(file),
- })
+ opts.assets.uploadsAllowed,
+ )
const historyPanel = new HistoryPanel(shell.dockPanels['history']!, (steps) => {
const historyPanel = new HistoryPanel(shell.dockPanels['history']!, (steps) => {
for (let i = 0; i < Math.abs(steps); i++) history.undo()
for (let i = 0; i < Math.abs(steps); i++) history.undo()
@@ -64,6 +64,8 @@ export class AssetBrowser {
@@ -64,6 +64,8 @@ export class AssetBrowser {
constructor(
constructor(
private readonly root: HTMLElement,
private readonly root: HTMLElement,
private readonly callbacks: AssetBrowserCallbacks,
private readonly callbacks: AssetBrowserCallbacks,
+ /** false in a creator world: no import (uploads are staff-only there for now). */
+ private readonly uploads = true,
) {}
) {}
setState(next: Partial<AssetBrowserState>): void {
setState(next: Partial<AssetBrowserState>): void {
@@ -71,21 +73,7 @@ export class AssetBrowser {
@@ -71,21 +73,7 @@ export class AssetBrowser {
this.render()
this.render()
}
}
- private render(): void {
- const { tab, filter } = this.state
- this.root.textContent = ''
-
- const tabs = document.createElement('div')
- tabs.className = 'asset-tabs'
- for (const name of ['textures', 'models'] as const) {
- const b = document.createElement('button')
- b.type = 'button'
- b.textContent = name
- b.className = tab === name ? 'active' : ''
- b.addEventListener('click', () => this.setState({ tab: name }))
- tabs.append(b)
- }
- // Import lives beside the list it adds to, so the flow is obvious.
+ private importControl(tab: 'textures' | 'models'): HTMLLabelElement {
const importLabel = document.createElement('label')
const importLabel = document.createElement('label')
importLabel.className = 'mini asset-import'
importLabel.className = 'mini asset-import'
importLabel.textContent = tab === 'textures' ? '+ Import texture' : '+ Import GLB'
importLabel.textContent = tab === 'textures' ? '+ Import texture' : '+ Import GLB'
@@ -102,7 +90,32 @@ export class AssetBrowser {
@@ -102,7 +90,32 @@ export class AssetBrowser {
importInput.value = ''
importInput.value = ''
})
})
importLabel.append(importInput)
importLabel.append(importInput)
- tabs.append(importLabel)
+ return importLabel
+ }
+
+ private render(): void {
+ const { tab, filter } = this.state
+ this.root.textContent = ''
+
+ const tabs = document.createElement('div')
+ tabs.className = 'asset-tabs'
+ for (const name of ['textures', 'models'] as const) {
+ const b = document.createElement('button')
+ b.type = 'button'
+ b.textContent = name
+ b.className = tab === name ? 'active' : ''
+ b.addEventListener('click', () => this.setState({ tab: name }))
+ tabs.append(b)
+ }
+ // Import lives beside the list it adds to, so the flow is obvious. A creator world says why there is none.
+ if (this.uploads) tabs.append(this.importControl(tab))
+ else {
+ const note = document.createElement('span')
+ note.className = 'mini asset-import'
+ note.textContent = 'Built-in materials only here'
+ note.title = 'Uploaded textures and models stay with staff maps for now.'
+ tabs.append(note)
+ }
this.root.append(tabs)
this.root.append(tabs)
const needle = filter.trim().toLowerCase()
const needle = filter.trim().toLowerCase()