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 <noreply@anthropic.com>
This commit is contained in:
1 parent
24d360aeec
commit
cab1c0177f
17 files changed
+356
-8
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { GamePane } from './viewport/GamePane';
|
|||||||
import { ScenePane } from './viewport/ScenePane';
|
import { ScenePane } from './viewport/ScenePane';
|
||||||
import { GameViewport } from './viewport/GameViewport';
|
import { GameViewport } from './viewport/GameViewport';
|
||||||
import { SceneViewport } from './viewport/SceneViewport';
|
import { SceneViewport } from './viewport/SceneViewport';
|
||||||
|
import { leaveFullscreen, onFullscreenChange } from '../viewport/fullscreen';
|
||||||
import { TAB_OF_ASSET } from './assetTabs';
|
import { TAB_OF_ASSET } from './assetTabs';
|
||||||
import { attachAutosave } from './autosave';
|
import { attachAutosave } from './autosave';
|
||||||
import { PasswordDialog } from './auth/PasswordDialog';
|
import { PasswordDialog } from './auth/PasswordDialog';
|
||||||
@@ -463,6 +464,8 @@ export class Editor implements PlayHost {
|
|||||||
const onTick = (ticker: Ticker) => this.tick(ticker.deltaMS / 1000);
|
const onTick = (ticker: Ticker) => this.tick(ticker.deltaMS / 1000);
|
||||||
app.ticker.add(onTick);
|
app.ticker.add(onTick);
|
||||||
this.cleanups.push(() => app.ticker.remove(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.
|
// In the game view Tab is a game key (an inventory, a target switch) – like every other key there.
|
||||||
const focus = ui().focus;
|
const focus = ui().focus;
|
||||||
focus.tabBelongsElsewhere = () => this.tab === 'game';
|
focus.tabBelongsElsewhere = () => this.tab === 'game';
|
||||||
@@ -690,6 +693,28 @@ export class Editor implements PlayHost {
|
|||||||
else this.showTab(tab);
|
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 {
|
private kindOf(doc: DocumentId): CentreTab | null {
|
||||||
if (doc.startsWith('values:')) {
|
if (doc.startsWith('values:')) {
|
||||||
const target = this.valueTargets.get(doc);
|
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`. */
|
/** 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 {
|
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.tab = tab;
|
||||||
this.documents = { docs: this.documents.docs, active: strip && isDocument(strip) ? strip : null };
|
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.
|
// 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<void> {
|
async dispose(): Promise<void> {
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
|
this.endGameFullscreen();
|
||||||
await this.autosave.flush();
|
await this.autosave.flush();
|
||||||
this.autosave.stop();
|
this.autosave.stop();
|
||||||
if (this.play.isPlaying) this.play.stop();
|
if (this.play.isPlaying) this.play.stop();
|
||||||
|
|||||||
@@ -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 { Editor } from '../Editor';
|
||||||
import type { SnapToggle } from '../graph/SnapToggle';
|
import type { SnapToggle } from '../graph/SnapToggle';
|
||||||
import type { EditorState } from '../state';
|
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 };
|
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. */
|
/** A tab's magnet (#194): where to click it, how big it is drawn, and whether it is on. */
|
||||||
export function snapButton(snap: SnapToggle): {
|
export function snapButton(snap: SnapToggle): {
|
||||||
x: number;
|
x: number;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { currentVisibilityPopup } from '../panels/SceneVisibilityPopup';
|
|||||||
import { currentTilemapWidgets, paintedLayerId, paintedTilemap } from '../panels/TilemapPanel';
|
import { currentTilemapWidgets, paintedLayerId, paintedTilemap } from '../panels/TilemapPanel';
|
||||||
import type { TilemapTool } from '../state';
|
import type { TilemapTool } from '../state';
|
||||||
import { rectOf } from '../textProbe';
|
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. */
|
/** The Scene and Game tabs, the tilemap painted in the scene view, and the Container tab. */
|
||||||
export function sceneHooks({ state, editor }: HookContext) {
|
export function sceneHooks({ state, editor }: HookContext) {
|
||||||
@@ -96,6 +96,16 @@ export function sceneHooks({ state, editor }: HookContext) {
|
|||||||
picker: centreOf(gamePane.scalePicker),
|
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. */
|
/** Which typeface a text ended up drawn in, and how wide it made it. */
|
||||||
textInfo: (id: string) => viewport.textInfo(id),
|
textInfo: (id: string) => viewport.textInfo(id),
|
||||||
gameTextInfo: (id: string) => gameView.textInfo(id),
|
gameTextInfo: (id: string) => gameView.textInfo(id),
|
||||||
|
|||||||
@@ -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 { GAME_SCALE_STEPS, type GameScale } from '../../viewport/view';
|
||||||
import { TabHeader } from '../TabHeader';
|
import { TabHeader } from '../TabHeader';
|
||||||
import type { GameViewport } from './GameViewport';
|
import type { GameViewport } from './GameViewport';
|
||||||
@@ -6,11 +7,16 @@ import type { GameViewport } from './GameViewport';
|
|||||||
/** Room for the longest label, "Auto · 400%". */
|
/** Room for the longest label, "Auto · 400%". */
|
||||||
const SCALE_WIDTH = 110;
|
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 {
|
export class GamePane extends VStack {
|
||||||
readonly header = new TabHeader();
|
readonly header = new TabHeader();
|
||||||
/** Public for the check scripts. */
|
/** Public for the check scripts. */
|
||||||
readonly scalePicker: Dropdown;
|
readonly scalePicker: Dropdown;
|
||||||
|
readonly fullscreenButton = new Button('', {
|
||||||
|
width: Theme.controlHeight,
|
||||||
|
icon: UI_ICON_SVG.fullscreen,
|
||||||
|
onClick: () => void toggleFullscreen(),
|
||||||
|
});
|
||||||
|
|
||||||
constructor(readonly view: GameViewport) {
|
constructor(readonly view: GameViewport) {
|
||||||
super();
|
super();
|
||||||
@@ -19,6 +25,8 @@ export class GamePane extends VStack {
|
|||||||
onClick: () => this.openScalePicker(),
|
onClick: () => this.openScalePicker(),
|
||||||
});
|
});
|
||||||
this.header.add(this.scalePicker);
|
this.header.add(this.scalePicker);
|
||||||
|
this.header.add(new Spacer(), 1);
|
||||||
|
this.header.add(this.fullscreenButton);
|
||||||
this.add(this.header);
|
this.add(this.header);
|
||||||
this.add(view, 1);
|
this.add(view, 1);
|
||||||
view.onScaleShown = (factor) => this.scalePicker.setText(scaleLabel(view.scaleMode, factor));
|
view.onScaleShown = (factor) => this.scalePicker.setText(scaleLabel(view.scaleMode, factor));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { EditorState } from '../state';
|
|||||||
import type { World } from '../../runtime/World';
|
import type { World } from '../../runtime/World';
|
||||||
import { Theme, textStyle, ui } from '../../ui';
|
import { Theme, textStyle, ui } from '../../ui';
|
||||||
import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
|
import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
|
||||||
|
import { FullscreenCorner } from '../../viewport/FullscreenCorner';
|
||||||
import { collectHolderItems, collectWorldItems } from '../../viewport/items';
|
import { collectHolderItems, collectWorldItems } from '../../viewport/items';
|
||||||
import { routePointer } from '../../viewport/gamePointer';
|
import { routePointer } from '../../viewport/gamePointer';
|
||||||
import { cameraFactor, cameraView, type GameScale, type View } from '../../viewport/view';
|
import { cameraFactor, cameraView, type GameScale, type View } from '../../viewport/view';
|
||||||
@@ -26,6 +27,9 @@ export class GameViewport extends WorldViewport<EditorState> {
|
|||||||
private factor: number | null = null;
|
private factor: number | null = null;
|
||||||
/** Told the factor of every redraw: auto and fit change with the view. */
|
/** Told the factor of every redraw: auto and fit change with the view. */
|
||||||
onScaleShown: ((factor: number | null) => void) | null = null;
|
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) {
|
constructor(state: EditorState) {
|
||||||
super(state);
|
super(state);
|
||||||
@@ -34,6 +38,8 @@ export class GameViewport extends WorldViewport<EditorState> {
|
|||||||
this.addChild(this.hint);
|
this.addChild(this.hint);
|
||||||
this.editorRunCaption.eventMode = 'none';
|
this.editorRunCaption.eventMode = 'none';
|
||||||
this.addChild(this.editorRunCaption);
|
this.addChild(this.editorRunCaption);
|
||||||
|
this.addChild(this.fullscreenButton);
|
||||||
|
this.fullscreenButton.sync(false, false);
|
||||||
routePointer(
|
routePointer(
|
||||||
this,
|
this,
|
||||||
() => this.pointerWorld(),
|
() => this.pointerWorld(),
|
||||||
@@ -44,9 +50,20 @@ export class GameViewport extends WorldViewport<EditorState> {
|
|||||||
/** null = not playing. */
|
/** null = not playing. */
|
||||||
setWorld(world: World | null): void {
|
setWorld(world: World | null): void {
|
||||||
this.playWorld = world;
|
this.playWorld = world;
|
||||||
|
this.fullscreenButton.sync(this.fullscreen, world !== null);
|
||||||
this.redraw();
|
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. */
|
/** null = no behavior is running outside play mode. */
|
||||||
setEditorWorld(world: World | null): void {
|
setEditorWorld(world: World | null): void {
|
||||||
this.editorWorld = world;
|
this.editorWorld = world;
|
||||||
@@ -134,5 +151,6 @@ export class GameViewport extends WorldViewport<EditorState> {
|
|||||||
// Which tab holds the keyboard is otherwise nowhere to be seen.
|
// Which tab holds the keyboard is otherwise nowhere to be seen.
|
||||||
this.editorRunCaption.visible = this.editorWorld !== null && !this.playWorld;
|
this.editorRunCaption.visible = this.editorWorld !== null && !this.playWorld;
|
||||||
this.editorRunCaption.position.set(8, 6);
|
this.editorRunCaption.position.set(8, 6);
|
||||||
|
this.fullscreenButton.place(this.w);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Text, type TextStyle } from 'pixi.js';
|
|||||||
import type { World } from '../runtime/World';
|
import type { World } from '../runtime/World';
|
||||||
import { Theme, textStyle, ui } from '../ui';
|
import { Theme, textStyle, ui } from '../ui';
|
||||||
import { cameraOfWorld } from '../viewport/activeCamera';
|
import { cameraOfWorld } from '../viewport/activeCamera';
|
||||||
|
import { FullscreenCorner } from '../viewport/FullscreenCorner';
|
||||||
import { routePointer } from '../viewport/gamePointer';
|
import { routePointer } from '../viewport/gamePointer';
|
||||||
import { collectWorldItems } from '../viewport/items';
|
import { collectWorldItems } from '../viewport/items';
|
||||||
import { cameraView, type View } from '../viewport/view';
|
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. */
|
/** Asked when the caption is clicked while it offers to start. */
|
||||||
onStart: (() => void) | null = null;
|
onStart: (() => void) | null = null;
|
||||||
private startable = false;
|
private startable = false;
|
||||||
|
readonly fullscreenButton = new FullscreenCorner(this);
|
||||||
|
|
||||||
constructor(source: ViewSource) {
|
constructor(source: ViewSource) {
|
||||||
super(source);
|
super(source);
|
||||||
@@ -35,6 +37,7 @@ export class GameScreen extends WorldViewport {
|
|||||||
text.anchor.set(0.5);
|
text.anchor.set(0.5);
|
||||||
this.addChild(text);
|
this.addChild(text);
|
||||||
}
|
}
|
||||||
|
this.addChild(this.fullscreenButton);
|
||||||
routePointer(
|
routePointer(
|
||||||
this,
|
this,
|
||||||
() => this.running,
|
() => this.running,
|
||||||
@@ -59,6 +62,7 @@ export class GameScreen extends WorldViewport {
|
|||||||
/** null = not running. */
|
/** null = not running. */
|
||||||
setWorld(world: World | null): void {
|
setWorld(world: World | null): void {
|
||||||
this.running = world;
|
this.running = world;
|
||||||
|
this.fullscreenButton.sync(true, world !== null);
|
||||||
this.redraw();
|
this.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,5 +105,6 @@ export class GameScreen extends WorldViewport {
|
|||||||
this.caption.position.set(this.w / 2, this.h / 2);
|
this.caption.position.set(this.w / 2, this.h / 2);
|
||||||
this.hint.visible = this.running !== null && view === null;
|
this.hint.visible = this.running !== null && view === null;
|
||||||
this.hint.position.set(this.w / 2, this.h / 2);
|
this.hint.position.set(this.w / 2, this.h / 2);
|
||||||
|
this.fullscreenButton.place(this.w);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,5 +13,14 @@ export function installPlayerHook(screen: GameScreen, player: () => Player | nul
|
|||||||
screen: () => screen.info(),
|
screen: () => screen.info(),
|
||||||
log: () => player()?.log() ?? [],
|
log: () => player()?.log() ?? [],
|
||||||
pixelAt: (x: number, y: number) => stagePixel(ui().app, x, y),
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+3
-1
@@ -3,7 +3,9 @@
|
|||||||
* come from whatever font the system has, so they are SVG files like every other icon.
|
* come from whatever font the system has, so they are SVG files like every other icon.
|
||||||
*/
|
*/
|
||||||
import current from './icons/current.svg?raw';
|
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 ticked from './icons/ticked.svg?raw';
|
||||||
import unticked from './icons/unticked.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 };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="fullscreen-exit" viewBox="-8 -8 16 16" width="16" height="16">
|
||||||
|
<path d="M-6 -2 L-2 -2 L-2 -6" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<path d="M2 -6 L2 -2 L6 -2" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<path d="M6 2 L2 2 L2 6" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<path d="M-2 6 L-2 2 L-6 2" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 458 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="fullscreen" viewBox="-8 -8 16 16" width="16" height="16">
|
||||||
|
<path d="M-6 -2 L-6 -6 L-2 -6" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<path d="M2 -6 L6 -6 L6 -2" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<path d="M6 2 L6 6 L2 6" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<path d="M-2 6 L-6 6 L-6 2" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 453 B |
@@ -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<Button['destroy']>[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -77,8 +77,8 @@ function game(id) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Releasing a project (#266): published from the main menu, played from the link without a
|
* 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
|
* session – only the game, zoomed to Auto, on the page colour, with a fullscreen button in the
|
||||||
* taken down.
|
* 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
|
* 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.
|
* project and open the fixture again.
|
||||||
@@ -230,6 +230,7 @@ export async function release(t) {
|
|||||||
await click(size.w / 2, size.h / 2);
|
await click(size.w / 2, size.h / 2);
|
||||||
await waitUntil('__gameMakerPlay.running()', { label: 'the game runs' });
|
await waitUntil('__gameMakerPlay.running()', { label: 'the game runs' });
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
|
const cornerAfterClick = await player('fullscreen()');
|
||||||
const screen = await player('screen()');
|
const screen = await player('screen()');
|
||||||
const centre = { x: screen.frame.x + screen.frame.w / 2, y: screen.frame.y + screen.frame.h / 2 };
|
const centre = { x: screen.frame.x + screen.frame.w / 2, y: screen.frame.y + screen.frame.h / 2 };
|
||||||
const scale = screen.scale;
|
const scale = screen.scale;
|
||||||
@@ -256,6 +257,23 @@ export async function release(t) {
|
|||||||
playingCursor,
|
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.
|
// 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 holdKey('ArrowRight', 500);
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
@@ -267,6 +285,22 @@ export async function release(t) {
|
|||||||
moved === BACKGROUND && (await player('screen()')).caption === 'Click to play again',
|
moved === BACKGROUND && (await player('screen()')).caption === 'Click to play again',
|
||||||
JSON.stringify({ moved, screen: await player('screen()') }),
|
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(' | '));
|
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.
|
// 60f. Take down asks, then the link is dead – for the API and for the page.
|
||||||
|
|||||||
@@ -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(' | '));
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ import { objectReferences } from './57-object-references.mjs';
|
|||||||
import { clipAreas } from './58-clip-areas.mjs';
|
import { clipAreas } from './58-clip-areas.mjs';
|
||||||
import { whilePlaying } from './59-while-playing.mjs';
|
import { whilePlaying } from './59-while-playing.mjs';
|
||||||
import { release } from './60-release.mjs';
|
import { release } from './60-release.mjs';
|
||||||
|
import { fullscreen } from './61-fullscreen.mjs';
|
||||||
|
|
||||||
export const STEPS = [
|
export const STEPS = [
|
||||||
hierarchy,
|
hierarchy,
|
||||||
@@ -125,4 +126,5 @@ export const STEPS = [
|
|||||||
clipAreas,
|
clipAreas,
|
||||||
whilePlaying,
|
whilePlaying,
|
||||||
release,
|
release,
|
||||||
|
fullscreen,
|
||||||
];
|
];
|
||||||
Reference in new issue
Block a user