diff --git a/public/ioquake3/player.html b/public/ioquake3/player.html index afbd073..00dcfc4 100644 --- a/public/ioquake3/player.html +++ b/public/ioquake3/player.html @@ -287,6 +287,7 @@ } const archives = [...stored.archives]; if (launch.packagePk3) archives.push({ name: `${launch.mapName}.pk3`, data: launch.packagePk3 }); + if (launch.previewPk3) archives.push({ name: 'zzzz-q3edit-preview.pk3', data: launch.previewPk3 }); return { archives, gameDir: configuredGameDir }; } @@ -302,13 +303,18 @@ ? stored.archives.filter((archive) => /^pak[0-9]+\.pk3$/i.test(safeFileName(archive.name))) : stored.archives; if (launch.packagePk3) archives.push({ name: `${launch.mapName}.pk3`, data: launch.packagePk3 }); + if (launch.previewPk3) archives.push({ name: 'zzzz-q3edit-preview.pk3', data: launch.previewPk3 }); return { archives, gameDir: 'baseq3' }; } if (stored.openArenaEnabled) { const archives = await loadOpenArenaArchives(); - if (launch.packagePk3) archives.push({ name: `${launch.mapName}.pk3`, data: launch.packagePk3 }); - else archives.push(...stored.archives); + if (launch.packagePk3) { + archives.push({ name: `${launch.mapName}.pk3`, data: launch.packagePk3 }); + } else { + archives.push(...stored.archives); + if (launch.previewPk3) archives.push({ name: 'zzzz-q3edit-preview.pk3', data: launch.previewPk3 }); + } return { archives, gameDir: 'baseoa' }; } @@ -329,12 +335,12 @@ setStatus(`Installing ${name} (${index + 1} of ${archives.length})…`); module.FS.writeFile(`${root}/${name}`, new Uint8Array(archive.data)); } - // Package preview must resolve the BSP and AAS from the produced PK3. - // Writing loose copies here would hide missing package entries. - if (!launch.packagePk3) { - module.FS.writeFile(`${root}/maps/${launch.mapName}.bsp`, new Uint8Array(launch.bsp)); + // Package and direct Quick Play previews resolve the BSP from their + // generated PK3, keeping lookup priority consistent across games. + if (!launch.packagePk3 && !launch.previewPk3) { + module.FS.writeFile(`${root}/maps/${launch.runtimeMapName}.bsp`, new Uint8Array(launch.bsp)); if (launch.aas) { - module.FS.writeFile(`${root}/maps/${launch.mapName}.aas`, new Uint8Array(launch.aas)); + module.FS.writeFile(`${root}/maps/${launch.runtimeMapName}.aas`, new Uint8Array(launch.aas)); } } } @@ -387,7 +393,7 @@ '+set', 'bot_minplayers', String(launch.botCount + 1), ] : []), '+set', 'com_basegame', assets.gameDir, - '+devmap', launch.mapName, + '+devmap', launch.runtimeMapName, ...((launch.botCount > 0 || launch.noclip || (launch.commands || []).length > 0) ? ['+wait', '30'] : []), ...botArguments, ...(launch.noclip ? ['+noclip'] : []), @@ -451,6 +457,7 @@ if (event.data?.type !== 'q3edit-player:launch' || started) return; started = true; const mapName = String(event.data.mapName || 'compile').replace(/[^a-zA-Z0-9_-]/g, '') || 'compile'; + const runtimeMapName = String(event.data.runtimeMapName || mapName).replace(/[^a-zA-Z0-9_-]/g, '') || mapName; const bsp = event.data.bsp; if (!(bsp instanceof ArrayBuffer)) { fail(new Error('The compiled BSP data was not provided')); @@ -466,10 +473,11 @@ Array.isArray(command.args) && command.args.every(argument => typeof argument === 'string') ) : []; const packagePk3 = event.data.packagePk3 instanceof ArrayBuffer ? event.data.packagePk3 : null; + const previewPk3 = event.data.previewPk3 instanceof ArrayBuffer ? event.data.previewPk3 : null; // Reading the stored project is also a compatibility fallback for an // editor tab that was already open when the player shell was updated. const gameDirectory = safeGameDirectory(event.data.gameDirectory ?? loadProjectGameDirectory()); - void start({ mapName, gameDirectory, bsp, aas, packagePk3, botCount, botSkill, noclip: event.data.noclip === true, commands }); + void start({ mapName, runtimeMapName, gameDirectory, bsp, aas, packagePk3, previewPk3, botCount, botSkill, noclip: event.data.noclip === true, commands }); }); window.addEventListener('error', (event) => fail(event.error ?? event.message)); diff --git a/src/game-preview.ts b/src/game-preview.ts new file mode 100644 index 0000000..8c9301d --- /dev/null +++ b/src/game-preview.ts @@ -0,0 +1,26 @@ +import { zipSync } from 'fflate'; + +/** + * Give loose Quick Play builds a content-addressed name so an older BSP with + * the document's filename in an enabled PK3 cannot win Quake's search lookup. + */ +export function quickPlayRuntimeMapName(mapName: string, bsp: Uint8Array): string { + let hash = 2166136261; + for (const byte of bsp) hash = Math.imul(hash ^ byte, 16777619); + + const safeBase = mapName.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 38) || 'compile'; + const fingerprint = (hash >>> 0).toString(16).padStart(8, '0'); + return `${safeBase}_q3e_${fingerprint}`; +} + +/** Package the current compile as the highest-priority archive in the preview filesystem. */ +export function createQuickPlayPk3( + runtimeMapName: string, + bsp: Uint8Array, + aas: Uint8Array | null, +): Uint8Array { + return zipSync({ + [`maps/${runtimeMapName}.bsp`]: bsp, + ...(aas ? { [`maps/${runtimeMapName}.aas`]: aas } : {}), + }, { level: 0 }); +} diff --git a/src/release-notes/2026-08-05-game-export-preview.md b/src/release-notes/2026-08-05-game-export-preview.md new file mode 100644 index 0000000..bb74553 --- /dev/null +++ b/src/release-notes/2026-08-05-game-export-preview.md @@ -0,0 +1,18 @@ +--- +id: 2026-08-05-game-export-preview +title: August 5, 2026 — Game Export and Preview +date: 2026-08-05 +order: 2 +--- + +Packaging and testing maps now fit more naturally into both Maker projects and the editor's Quick Play workflow. + +## Maker export target + +- Release Package can export a game-level bundle for Maker projects. +- The export includes the compiled level and the supporting files needed by the game project. + +## Reliable Quick Play updates + +- Unsaved previews use a content-addressed runtime map name, preventing an older BSP in an enabled archive from taking precedence. +- Preview files are delivered as a temporary PK3 and the player page is cache-busted for each launch. diff --git a/src/ui.ts b/src/ui.ts index cdce96e..20aedcb 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -58,6 +58,7 @@ import { MapOrganizationController, type NavigationState } from './map-organizat import { openMapOrganizationDialog } from './map-organization-dialog'; import { SurfaceInspector } from './surface-inspector'; import { validateProjectShaderFiles } from './q3-shader-source'; +import { createQuickPlayPk3, quickPlayRuntimeMapName } from './game-preview'; export interface AssetLoadingHandle { ready: Promise; @@ -2311,6 +2312,12 @@ export class UI { this.editor.runtimeEntityMessages = []; const safeMapName = mapName.replace(/[^a-zA-Z0-9_-]/g, '') || 'compile'; + const runtimeMapName = packagePk3 + ? safeMapName + : quickPlayRuntimeMapName(safeMapName, bsp); + const previewPk3 = packagePk3 + ? null + : createQuickPlayPk3(runtimeMapName, bsp, aas); const bspCopy = new Uint8Array(bsp.byteLength); bspCopy.set(bsp); const retainedBsp = new Uint8Array(bsp.byteLength); @@ -2372,7 +2379,7 @@ export class UI { const frame = document.createElement('iframe'); frame.className = 'game-preview-frame'; frame.title = `ioquake3 preview of ${safeMapName}`; - frame.src = '/ioquake3/player.html'; + frame.src = `/ioquake3/player.html?v=${Date.now()}`; frame.allow = 'autoplay; fullscreen'; const actions = document.createElement('div'); @@ -2403,6 +2410,7 @@ export class UI { const launchMessage = { type: 'q3edit-player:launch', mapName: safeMapName, + runtimeMapName, gameDirectory: this.gamePreviewLaunch?.gameDirectory ?? 'baseq3', bsp: bspCopy.buffer, aas: aasCopy?.buffer ?? null, @@ -2411,11 +2419,13 @@ export class UI { noclip, commands, packagePk3: packageCopy?.buffer ?? null, + previewPk3: previewPk3?.buffer ?? null, }; const transfer = [ bspCopy.buffer, ...(aasCopy ? [aasCopy.buffer] : []), ...(packageCopy ? [packageCopy.buffer] : []), + ...(previewPk3 ? [previewPk3.buffer] : []), ]; frame.contentWindow?.postMessage(launchMessage, window.location.origin, transfer); } else if (message?.type === 'q3edit-player:status') { diff --git a/tests/game-preview.test.ts b/tests/game-preview.test.ts new file mode 100644 index 0000000..3bcfaf2 --- /dev/null +++ b/tests/game-preview.test.ts @@ -0,0 +1,32 @@ +import { unzipSync } from 'fflate'; +import { describe, expect, it } from 'vitest'; +import { createQuickPlayPk3, quickPlayRuntimeMapName } from '../src/game-preview'; + +describe('Quick Play runtime map names', () => { + it('uses BSP contents to avoid collisions with maps in enabled PK3 files', () => { + const first = quickPlayRuntimeMapName('test-map', new Uint8Array([1, 2, 3])); + const same = quickPlayRuntimeMapName('test-map', new Uint8Array([1, 2, 3])); + const rebuilt = quickPlayRuntimeMapName('test-map', new Uint8Array([1, 2, 4])); + + expect(first).toBe(same); + expect(first).toMatch(/^test-map_q3e_[0-9a-f]{8}$/); + expect(rebuilt).not.toBe(first); + }); + + it('produces a valid Quake path component within MAX_QPATH', () => { + const name = quickPlayRuntimeMapName('a very/long map name!'.repeat(8), new Uint8Array([9])); + + expect(name).toMatch(/^[a-zA-Z0-9_-]+$/); + expect(`maps/${name}.bsp`.length).toBeLessThan(64); + }); + + it('packages the current BSP and AAS under the runtime name', () => { + const bsp = new Uint8Array([1, 2, 3]); + const aas = new Uint8Array([4, 5]); + const files = unzipSync(createQuickPlayPk3('test-map_q3e_12345678', bsp, aas)); + + expect(files['maps/test-map_q3e_12345678.bsp']).toEqual(bsp); + expect(files['maps/test-map_q3e_12345678.aas']).toEqual(aas); + }); + +});