From cab1c0177f6dc1e44983d7535cbdfc3a6d322153 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 29 Sep 2026 14:44:14 +0200 Subject: [PATCH] Show only the game view in fullscreen, in the editor and the link (#267) A game is easier to judge and to play without the editor around it. The Game tab and the game link now both have a button that puts the game view itself into fullscreen, not just the browser window. - The Game tab header has a fullscreen button on the right. It hides toolbar, panels, tab headers and console and keeps the zoom from the picker. A running game keeps running. - A corner button at the top right, or Esc, leaves fullscreen and puts the view back into the tab where it was. - The game link shows the same corner button. Over a running game it hides after 2 s without mouse movement and returns on the next move. - A click on the corner button never reaches the game. - Stop ends fullscreen. Stop switches back to the Scene tab, and any switch away from the Game tab ends fullscreen, so no empty screen is left behind. - src/viewport/fullscreen.ts wraps the Fullscreen API and holds the rule for when the button shows. FullscreenCorner.ts is the button used by both pages. Two SVG icons are new. - Editor.showGameFullscreen moves the GameViewport into the root while fullscreen is on. Tests: - ./check.sh: 195 files, 2815 tests, exit 0 (3 new unit tests). - e2e 60: 2 new checks for the corner button on the game link. - e2e 61 (new): 4 checks for fullscreen in the editor. - Full e2e run (local): ALL PASSED, 1580 checks. - Counter-checks fail as expected: rule without "no game running", no stopPropagation (60h), no end on tab switch (61c), no reparenting of the view (61a). Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/editor/Editor.ts | 28 +++++++++++ src/editor/debug/hookHelpers.ts | 13 ++++- src/editor/debug/sceneHooks.ts | 12 ++++- src/editor/viewport/GamePane.ts | 12 ++++- src/editor/viewport/GameViewport.ts | 18 +++++++ src/player/GameScreen.ts | 5 ++ src/player/playerHook.ts | 9 ++++ src/ui/icons.ts | 4 +- src/ui/icons/fullscreen-exit.svg | 6 +++ src/ui/icons/fullscreen.svg | 6 +++ src/viewport/FullscreenCorner.ts | 78 +++++++++++++++++++++++++++++ src/viewport/fullscreen.ts | 44 ++++++++++++++++ tests/viewport/fullscreen.test.ts | 23 +++++++++ tools/e2e/60-release.mjs | 38 +++++++++++++- tools/e2e/61-fullscreen.mjs | 64 +++++++++++++++++++++++ tools/e2e/index.mjs | 2 + 17 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 src/ui/icons/fullscreen-exit.svg create mode 100644 src/ui/icons/fullscreen.svg create mode 100644 src/viewport/FullscreenCorner.ts create mode 100644 src/viewport/fullscreen.ts create mode 100644 tests/viewport/fullscreen.test.ts create mode 100644 tools/e2e/61-fullscreen.mjs diff --git a/README.md b/README.md index c1f5020b..3da7e27b 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1574 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1580 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index a85f95b2..cd4e4aec 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -33,6 +33,7 @@ import { GamePane } from './viewport/GamePane'; import { ScenePane } from './viewport/ScenePane'; import { GameViewport } from './viewport/GameViewport'; import { SceneViewport } from './viewport/SceneViewport'; +import { leaveFullscreen, onFullscreenChange } from '../viewport/fullscreen'; import { TAB_OF_ASSET } from './assetTabs'; import { attachAutosave } from './autosave'; import { PasswordDialog } from './auth/PasswordDialog'; @@ -463,6 +464,8 @@ export class Editor implements PlayHost { const onTick = (ticker: Ticker) => this.tick(ticker.deltaMS / 1000); app.ticker.add(onTick); this.cleanups.push(() => app.ticker.remove(onTick)); + // Only the game tab's button asks for fullscreen, so every change is the game view's (#267). + this.cleanups.push(onFullscreenChange((on) => this.showGameFullscreen(on))); // In the game view Tab is a game key (an inventory, a target switch) – like every other key there. const focus = ui().focus; focus.tabBelongsElsewhere = () => this.tab === 'game'; @@ -690,6 +693,28 @@ export class Editor implements PlayHost { else this.showTab(tab); } + /** + * In fullscreen the game view is all the page shows: it moves from its tab to the root, and the + * toolbar and the workspace step aside. Leaving puts it back where it was, below the tab header. + */ + private showGameFullscreen(on: boolean): void { + if (on === this.gameView.isFullscreen) return; + this.toolbar.visible = !on; + this.workspace.visible = !on; + if (on) this.root.add(this.gameView, 1); + else this.gamePane.add(this.gameView, 1); + this.gameView.setFullscreen(on); + this.root.relayout(); + // The pane may come back at the size it had, and a resize to the same size lays nothing out. + this.gamePane.relayout(); + } + + /** Back to the editor now, not when the browser reports it: the caller goes on with the layout. */ + private endGameFullscreen(): void { + leaveFullscreen(); + this.showGameFullscreen(false); + } + private kindOf(doc: DocumentId): CentreTab | null { if (doc.startsWith('values:')) { const target = this.valueTargets.get(doc); @@ -754,6 +779,8 @@ export class Editor implements PlayHost { /** Shows the pane for `tab`, with `strip` lit in the row – none for an empty kind reached by `showTab`. */ private display(tab: TabId, strip: StripTab | null): void { + // Fullscreen is the game view's; a tab that takes its place (Stop goes back to the scene) ends it. + if (tab !== 'game') this.endGameFullscreen(); this.tab = tab; this.documents = { docs: this.documents.docs, active: strip && isDocument(strip) ? strip : null }; // The hierarchy lists what the visible tab edits; every other tab is about the scene. @@ -817,6 +844,7 @@ export class Editor implements PlayHost { async dispose(): Promise { if (this.disposed) return; this.disposed = true; + this.endGameFullscreen(); await this.autosave.flush(); this.autosave.stop(); if (this.play.isPlaying) this.play.stop(); diff --git a/src/editor/debug/hookHelpers.ts b/src/editor/debug/hookHelpers.ts index ae6b2ab3..af1d2ca4 100644 --- a/src/editor/debug/hookHelpers.ts +++ b/src/editor/debug/hookHelpers.ts @@ -1,4 +1,5 @@ -import type { Application } from 'pixi.js'; +import type { Application, Container } from 'pixi.js'; +import { ui } from '../../ui'; import type { Editor } from '../Editor'; import type { SnapToggle } from '../graph/SnapToggle'; import type { EditorState } from '../state'; @@ -18,6 +19,16 @@ export function centreOf(widget: { getGlobalPosition(): { x: number; y: number } return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 }; } +/** Whether `node` and everything it hangs from are visible, up to a stage. */ +export function shownOnStage(node: Container): boolean { + let current = node; + while (current.visible) { + if (!current.parent) return current === ui().app.stage; + current = current.parent; + } + return false; +} + /** A tab's magnet (#194): where to click it, how big it is drawn, and whether it is on. */ export function snapButton(snap: SnapToggle): { x: number; diff --git a/src/editor/debug/sceneHooks.ts b/src/editor/debug/sceneHooks.ts index 3bdb54db..5d6cdc7e 100644 --- a/src/editor/debug/sceneHooks.ts +++ b/src/editor/debug/sceneHooks.ts @@ -5,7 +5,7 @@ import { currentVisibilityPopup } from '../panels/SceneVisibilityPopup'; import { currentTilemapWidgets, paintedLayerId, paintedTilemap } from '../panels/TilemapPanel'; import type { TilemapTool } from '../state'; import { rectOf } from '../textProbe'; -import { centreOf, type HookContext } from './hookHelpers'; +import { centreOf, type HookContext, shownOnStage } from './hookHelpers'; /** The Scene and Game tabs, the tilemap painted in the scene view, and the Container tab. */ export function sceneHooks({ state, editor }: HookContext) { @@ -96,6 +96,16 @@ export function sceneHooks({ state, editor }: HookContext) { picker: centreOf(gamePane.scalePicker), }; }, + /** + * Fullscreen of the game view (#267): whether the view is moved to the root, whether the browser + * is in fullscreen, the header button, and the corner button while it shows. + */ + gameFullscreen: () => ({ + on: gameView.isFullscreen, + document: document.fullscreenElement !== null, + headerButton: shownOnStage(gamePane.fullscreenButton) ? centreOf(gamePane.fullscreenButton) : null, + corner: shownOnStage(gameView.fullscreenButton) ? centreOf(gameView.fullscreenButton) : null, + }), /** Which typeface a text ended up drawn in, and how wide it made it. */ textInfo: (id: string) => viewport.textInfo(id), gameTextInfo: (id: string) => gameView.textInfo(id), diff --git a/src/editor/viewport/GamePane.ts b/src/editor/viewport/GamePane.ts index bb7c0968..5a57d0be 100644 --- a/src/editor/viewport/GamePane.ts +++ b/src/editor/viewport/GamePane.ts @@ -1,4 +1,5 @@ -import { Dropdown, Menu, type MenuItem, VStack } from '../../ui'; +import { Button, Dropdown, Menu, type MenuItem, Spacer, Theme, UI_ICON_SVG, VStack } from '../../ui'; +import { toggleFullscreen } from '../../viewport/fullscreen'; import { GAME_SCALE_STEPS, type GameScale } from '../../viewport/view'; import { TabHeader } from '../TabHeader'; import type { GameViewport } from './GameViewport'; @@ -6,11 +7,16 @@ import type { GameViewport } from './GameViewport'; /** Room for the longest label, "Auto · 400%". */ const SCALE_WIDTH = 110; -/** The game tab (#225): a header with the scale picker above the game view. */ +/** The game tab (#225): a header with the scale picker and the fullscreen button above the game view. */ export class GamePane extends VStack { readonly header = new TabHeader(); /** Public for the check scripts. */ readonly scalePicker: Dropdown; + readonly fullscreenButton = new Button('', { + width: Theme.controlHeight, + icon: UI_ICON_SVG.fullscreen, + onClick: () => void toggleFullscreen(), + }); constructor(readonly view: GameViewport) { super(); @@ -19,6 +25,8 @@ export class GamePane extends VStack { onClick: () => this.openScalePicker(), }); this.header.add(this.scalePicker); + this.header.add(new Spacer(), 1); + this.header.add(this.fullscreenButton); this.add(this.header); this.add(view, 1); view.onScaleShown = (factor) => this.scalePicker.setText(scaleLabel(view.scaleMode, factor)); diff --git a/src/editor/viewport/GameViewport.ts b/src/editor/viewport/GameViewport.ts index 6c7ecfa7..2f592312 100644 --- a/src/editor/viewport/GameViewport.ts +++ b/src/editor/viewport/GameViewport.ts @@ -8,6 +8,7 @@ import type { EditorState } from '../state'; import type { World } from '../../runtime/World'; import { Theme, textStyle, ui } from '../../ui'; import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera'; +import { FullscreenCorner } from '../../viewport/FullscreenCorner'; import { collectHolderItems, collectWorldItems } from '../../viewport/items'; import { routePointer } from '../../viewport/gamePointer'; import { cameraFactor, cameraView, type GameScale, type View } from '../../viewport/view'; @@ -26,6 +27,9 @@ export class GameViewport extends WorldViewport { private factor: number | null = null; /** Told the factor of every redraw: auto and fit change with the view. */ onScaleShown: ((factor: number | null) => void) | null = null; + /** The way back out of fullscreen (#267); the tab header has the way in. */ + readonly fullscreenButton = new FullscreenCorner(this); + private fullscreen = false; constructor(state: EditorState) { super(state); @@ -34,6 +38,8 @@ export class GameViewport extends WorldViewport { this.addChild(this.hint); this.editorRunCaption.eventMode = 'none'; this.addChild(this.editorRunCaption); + this.addChild(this.fullscreenButton); + this.fullscreenButton.sync(false, false); routePointer( this, () => this.pointerWorld(), @@ -44,9 +50,20 @@ export class GameViewport extends WorldViewport { /** null = not playing. */ setWorld(world: World | null): void { this.playWorld = world; + this.fullscreenButton.sync(this.fullscreen, world !== null); this.redraw(); } + get isFullscreen(): boolean { + return this.fullscreen; + } + + /** Whether this view is all the screen shows; the editor moves it there and back. */ + setFullscreen(on: boolean): void { + this.fullscreen = on; + this.fullscreenButton.sync(on, this.playWorld !== null); + } + /** null = no behavior is running outside play mode. */ setEditorWorld(world: World | null): void { this.editorWorld = world; @@ -134,5 +151,6 @@ export class GameViewport extends WorldViewport { // Which tab holds the keyboard is otherwise nowhere to be seen. this.editorRunCaption.visible = this.editorWorld !== null && !this.playWorld; this.editorRunCaption.position.set(8, 6); + this.fullscreenButton.place(this.w); } } diff --git a/src/player/GameScreen.ts b/src/player/GameScreen.ts index 56ee4b12..258592cb 100644 --- a/src/player/GameScreen.ts +++ b/src/player/GameScreen.ts @@ -2,6 +2,7 @@ import { Text, type TextStyle } from 'pixi.js'; import type { World } from '../runtime/World'; import { Theme, textStyle, ui } from '../ui'; import { cameraOfWorld } from '../viewport/activeCamera'; +import { FullscreenCorner } from '../viewport/FullscreenCorner'; import { routePointer } from '../viewport/gamePointer'; import { collectWorldItems } from '../viewport/items'; import { cameraView, type View } from '../viewport/view'; @@ -27,6 +28,7 @@ export class GameScreen extends WorldViewport { /** Asked when the caption is clicked while it offers to start. */ onStart: (() => void) | null = null; private startable = false; + readonly fullscreenButton = new FullscreenCorner(this); constructor(source: ViewSource) { super(source); @@ -35,6 +37,7 @@ export class GameScreen extends WorldViewport { text.anchor.set(0.5); this.addChild(text); } + this.addChild(this.fullscreenButton); routePointer( this, () => this.running, @@ -59,6 +62,7 @@ export class GameScreen extends WorldViewport { /** null = not running. */ setWorld(world: World | null): void { this.running = world; + this.fullscreenButton.sync(true, world !== null); this.redraw(); } @@ -101,5 +105,6 @@ export class GameScreen extends WorldViewport { this.caption.position.set(this.w / 2, this.h / 2); this.hint.visible = this.running !== null && view === null; this.hint.position.set(this.w / 2, this.h / 2); + this.fullscreenButton.place(this.w); } } diff --git a/src/player/playerHook.ts b/src/player/playerHook.ts index 8e338f25..24ddc5c0 100644 --- a/src/player/playerHook.ts +++ b/src/player/playerHook.ts @@ -13,5 +13,14 @@ export function installPlayerHook(screen: GameScreen, player: () => Player | nul screen: () => screen.info(), log: () => player()?.log() ?? [], pixelAt: (x: number, y: number) => stagePixel(ui().app, x, y), + /** Whether the page is in fullscreen, and the corner button while it shows (#267). */ + fullscreen: () => { + const button = screen.fullscreenButton; + const origin = button.getGlobalPosition(); + return { + on: document.fullscreenElement !== null, + button: button.visible ? { x: origin.x + button.w / 2, y: origin.y + button.h / 2 } : null, + }; + }, }; } diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 747c454c..14e500d4 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -3,7 +3,9 @@ * come from whatever font the system has, so they are SVG files like every other icon. */ import current from './icons/current.svg?raw'; +import fullscreenExit from './icons/fullscreen-exit.svg?raw'; +import fullscreen from './icons/fullscreen.svg?raw'; import ticked from './icons/ticked.svg?raw'; import unticked from './icons/unticked.svg?raw'; -export const UI_ICON_SVG = { current, ticked, unticked }; +export const UI_ICON_SVG = { current, fullscreen, fullscreenExit, ticked, unticked }; diff --git a/src/ui/icons/fullscreen-exit.svg b/src/ui/icons/fullscreen-exit.svg new file mode 100644 index 00000000..96cf835b --- /dev/null +++ b/src/ui/icons/fullscreen-exit.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/icons/fullscreen.svg b/src/ui/icons/fullscreen.svg new file mode 100644 index 00000000..1b4d96c2 --- /dev/null +++ b/src/ui/icons/fullscreen.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/viewport/FullscreenCorner.ts b/src/viewport/FullscreenCorner.ts new file mode 100644 index 00000000..2c4ad05d --- /dev/null +++ b/src/viewport/FullscreenCorner.ts @@ -0,0 +1,78 @@ +import type { Container, FederatedPointerEvent } from 'pixi.js'; +import { Button, requestRender, Theme, UI_ICON_SVG } from '../ui'; +import { + CORNER_BUTTON_IDLE_SECONDS, + isFullscreen, + onFullscreenChange, + showsCornerButton, + toggleFullscreen, +} from './fullscreen'; + +/** + * The fullscreen button in the top right corner of a game on screen (#267): on the player page + * always, in the editor while its game view is in fullscreen. Over a running game it steps aside + * once the mouse rests (`showsCornerButton`). + */ +export class FullscreenCorner extends Button { + private active = true; + private running = false; + private lastMove = performance.now(); + private hideTimer = 0; + private readonly stopChange: () => void; + + constructor(host: Container) { + super('', { + width: Theme.controlHeight, + icon: isFullscreen() ? UI_ICON_SVG.fullscreenExit : UI_ICON_SVG.fullscreen, + onClick: () => void toggleFullscreen(), + }); + this.focusable = false; + this.resize(Theme.controlHeight, Theme.controlHeight); + // The button lies on the game: a press on it must neither start the game nor reach it as a click. + const keep = (e: FederatedPointerEvent): void => e.stopPropagation(); + this.on('pointerdown', keep); + this.on('pointerup', keep); + this.on('pointerover', () => this.refresh()); + this.on('pointerout', () => this.refresh()); + host.on('globalpointermove', () => { + this.lastMove = performance.now(); + this.refresh(); + }); + this.stopChange = onFullscreenChange((on) => + this.setIcon(on ? UI_ICON_SVG.fullscreenExit : UI_ICON_SVG.fullscreen), + ); + } + + override destroy(options?: Parameters[0]): void { + this.stopChange(); + window.clearTimeout(this.hideTimer); + super.destroy(options); + } + + /** `active`: whether the host wants the button at all; `running`: whether a game is on screen. */ + sync(active: boolean, running: boolean): void { + this.active = active; + this.running = running; + this.refresh(); + } + + /** Top right, a gap in from both edges of a host `w` wide. */ + place(w: number): void { + this.position.set(Math.round(w - this.w - Theme.gap * 2), Theme.gap * 2); + } + + private refresh(): void { + window.clearTimeout(this.hideTimer); + const idleSeconds = (performance.now() - this.lastMove) / 1000; + const show = + this.active && showsCornerButton({ running: this.running, idleSeconds, overButton: this.hovered }); + if (show !== this.visible) { + this.visible = show; + requestRender(); + } + if (show && this.running && !this.hovered) { + const left = CORNER_BUTTON_IDLE_SECONDS - idleSeconds; + this.hideTimer = window.setTimeout(() => this.refresh(), Math.max(0, left * 1000) + 20); + } + } +} diff --git a/src/viewport/fullscreen.ts b/src/viewport/fullscreen.ts new file mode 100644 index 00000000..d808a965 --- /dev/null +++ b/src/viewport/fullscreen.ts @@ -0,0 +1,44 @@ +/** + * Fullscreen for the game view (#267): the browser's Fullscreen API on the whole page, which is the + * one canvas. What the canvas then shows – only the game – is up to the page that asked. + */ + +/** How long the corner button stays after the mouse last moved over a running game. */ +export const CORNER_BUTTON_IDLE_SECONDS = 2; + +/** + * Whether the corner button shows: always while no game runs, and over a running game only while + * the mouse moves or rests on the button – it would otherwise sit over the picture for good. + */ +export function showsCornerButton(state: { + running: boolean; + idleSeconds: number; + overButton: boolean; +}): boolean { + return !state.running || state.overButton || state.idleSeconds < CORNER_BUTTON_IDLE_SECONDS; +} + +export function isFullscreen(): boolean { + return document.fullscreenElement !== null; +} + +/** Enters or leaves fullscreen; a browser that refuses leaves everything as it was. */ +export async function toggleFullscreen(): Promise { + try { + if (isFullscreen()) await document.exitFullscreen(); + else await document.documentElement.requestFullscreen(); + } catch (error) { + console.warn('[fullscreen]', error); + } +} + +export function leaveFullscreen(): void { + if (isFullscreen()) void document.exitFullscreen().catch((error) => console.warn('[fullscreen]', error)); +} + +/** Calls `handler` whenever fullscreen starts or ends, by the button or by Esc; returns the call that stops it. */ +export function onFullscreenChange(handler: (on: boolean) => void): () => void { + const listener = (): void => handler(isFullscreen()); + document.addEventListener('fullscreenchange', listener); + return () => document.removeEventListener('fullscreenchange', listener); +} diff --git a/tests/viewport/fullscreen.test.ts b/tests/viewport/fullscreen.test.ts new file mode 100644 index 00000000..c4e6d798 --- /dev/null +++ b/tests/viewport/fullscreen.test.ts @@ -0,0 +1,23 @@ +/** When the fullscreen button in the corner of a game shows (#267). */ +import { describe, expect, it } from 'vitest'; +import { CORNER_BUTTON_IDLE_SECONDS, showsCornerButton } from '../../src/viewport/fullscreen'; + +describe('showsCornerButton', () => { + it('always shows while no game runs, however long the mouse rests', () => { + expect(showsCornerButton({ running: false, idleSeconds: 60, overButton: false })).toBe(true); + }); + + it('shows over a running game only while the mouse moved lately', () => { + expect(showsCornerButton({ running: true, idleSeconds: 0, overButton: false })).toBe(true); + expect( + showsCornerButton({ running: true, idleSeconds: CORNER_BUTTON_IDLE_SECONDS - 0.1, overButton: false }), + ).toBe(true); + expect( + showsCornerButton({ running: true, idleSeconds: CORNER_BUTTON_IDLE_SECONDS, overButton: false }), + ).toBe(false); + }); + + it('stays while the pointer rests on it', () => { + expect(showsCornerButton({ running: true, idleSeconds: 60, overButton: true })).toBe(true); + }); +}); diff --git a/tools/e2e/60-release.mjs b/tools/e2e/60-release.mjs index 818a3962..2ce6d9cb 100644 --- a/tools/e2e/60-release.mjs +++ b/tools/e2e/60-release.mjs @@ -77,8 +77,8 @@ function game(id) { /** * Releasing a project (#266): published from the main menu, played from the link without a - * session – only the game, zoomed to Auto, on the page colour – updated under the same link, and - * taken down. + * session – only the game, zoomed to Auto, on the page colour, with a fullscreen button in the + * corner (#267) – updated under the same link, and taken down. * * Works on a throwaway project of its own, like step 50. The last thing it does is delete that * project and open the fixture again. @@ -230,6 +230,7 @@ export async function release(t) { await click(size.w / 2, size.h / 2); await waitUntil('__gameMakerPlay.running()', { label: 'the game runs' }); await sleep(300); + const cornerAfterClick = await player('fullscreen()'); const screen = await player('screen()'); const centre = { x: screen.frame.x + screen.frame.w / 2, y: screen.frame.y + screen.frame.h / 2 }; const scale = screen.scale; @@ -256,6 +257,23 @@ export async function release(t) { playingCursor, ); + // 60g (#267). The fullscreen button sits in the top right corner while the mouse moves, steps + // aside once it rests over the running game, and is back with the next move. + await sleep(2300); + const rested = await player('fullscreen()'); + await t.mouse('mouseMoved', size.w / 2 + 30, size.h / 2 + 30); + await settle(); + const movedAgain = await player('fullscreen()'); + check( + 'over a running game the fullscreen button shows while the mouse moves and steps aside when it rests', + cornerAfterClick.button !== null && + cornerAfterClick.button.x > size.w - 60 && + cornerAfterClick.button.y < 60 && + rested.button === null && + movedAgain.button !== null, + JSON.stringify({ cornerAfterClick, rested, movedAgain }), + ); + // 60e. The keys go to the game: the arrow moves the crate out of the middle, S stops the game. await holdKey('ArrowRight', 500); await sleep(200); @@ -267,6 +285,22 @@ export async function release(t) { moved === BACKGROUND && (await player('screen()')).caption === 'Click to play again', JSON.stringify({ moved, screen: await player('screen()') }), ); + + // 60h (#267). A click on the corner button makes the page fullscreen and does not reach the + // game – it would start it again; a second click leaves fullscreen. + const corner = (await player('fullscreen()')).button; + if (corner) await click(corner.x, corner.y); + await sleep(500); + const entered = { ...(await player('fullscreen()')), running: await player('running()') }; + const exitAt = entered.button ?? corner; + if (exitAt) await click(exitAt.x, exitAt.y); + await sleep(500); + const left = await player('fullscreen()'); + check( + 'the corner button puts the page into fullscreen and back, without starting the game', + corner !== null && entered.on && !entered.running && !left.on, + JSON.stringify({ corner, entered, left }), + ); check('the play page threw nothing', exceptions().length === 0, exceptions().join(' | ')); // 60f. Take down asks, then the link is dead – for the API and for the page. diff --git a/tools/e2e/61-fullscreen.mjs b/tools/e2e/61-fullscreen.mjs new file mode 100644 index 00000000..b1026605 --- /dev/null +++ b/tools/e2e/61-fullscreen.mjs @@ -0,0 +1,64 @@ +/** + * Fullscreen of the game tab (#267): the button in its header puts the game view on its own into + * fullscreen – no toolbar, no panels, no header –, the game keeps running, and the button in the + * corner puts the view back under its header. Stopping the game from fullscreen ends it as well. + */ +export async function fullscreen(t) { + const { check, json, click, sleep, play, stop, shot, press, exceptions } = t; + const info = async () => ({ + ...(await json('__gm.gameFullscreen()')), + view: (await json('__gm.gameScale()')).view, + }); + const size = { w: await json('innerWidth'), h: await json('innerHeight') }; + + await press('Escape'); + await play(); + const windowed = await info(); + + // 61a. The header button: the game view alone fills the page, and the game goes on. + await click(windowed.headerButton.x, windowed.headerButton.y); + await sleep(500); + const full = { ...(await info()), mode: await json('__gm.state.mode') }; + check( + 'the header button puts the running game view alone into fullscreen', + windowed.view.y > 0 && + full.on && + full.document && + full.headerButton === null && + full.view.x === 0 && + full.view.y === 0 && + full.view.w === size.w && + full.view.h === size.h && + full.mode === 'play', + JSON.stringify({ size, windowed, full }), + ); + await shot('61-fullscreen'); + + // 61b. The corner button leaves it, and the view is back under its header, as big as before. + if (full.corner) await click(full.corner.x, full.corner.y); + await sleep(500); + const back = await info(); + check( + 'the corner button leaves fullscreen and puts the view back in its tab', + full.corner !== null && + !back.on && + !back.document && + back.headerButton !== null && + JSON.stringify(back.view) === JSON.stringify(windowed.view), + JSON.stringify({ corner: full.corner, back }), + ); + + // 61c. Stop goes back to the scene tab, which ends fullscreen too. + await click(back.headerButton.x, back.headerButton.y); + await sleep(500); + const again = await info(); + await stop(); + await sleep(300); + const stopped = await info(); + check( + 'stopping the game from fullscreen ends fullscreen', + again.on && !stopped.on && !stopped.document, + JSON.stringify({ again: again.on, stopped }), + ); + check('fullscreen threw nothing', exceptions().length === 0, exceptions().join(' | ')); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 28969885..6b041c62 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -63,6 +63,7 @@ import { objectReferences } from './57-object-references.mjs'; import { clipAreas } from './58-clip-areas.mjs'; import { whilePlaying } from './59-while-playing.mjs'; import { release } from './60-release.mjs'; +import { fullscreen } from './61-fullscreen.mjs'; export const STEPS = [ hierarchy, @@ -125,4 +126,5 @@ export const STEPS = [ clipAreas, whilePlaying, release, + fullscreen, ];