Show only the game view in fullscreen, in the editor and the link (#267)
game-maker / gate (push) Successful in 4m10s
game-maker / suite (push) Failing after 11m53s

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:
adminandClaude Opus 5.5 committed 2026-09-29 14:44:14 +02:00
1 parent 24d360aeec
commit cab1c0177f
17 files changed
+356 -8

No files matched your search

+28
View File
@@ -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<void> {
if (this.disposed) return;
this.disposed = true;
this.endGameFullscreen();
await this.autosave.flush();
this.autosave.stop();
if (this.play.isPlaying) this.play.stop();
+12 -1
View File
@@ -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;
+11 -1
View File
@@ -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),
+10 -2
View File
@@ -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));
+18
View File
@@ -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<EditorState> {
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<EditorState> {
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<EditorState> {
/** 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<EditorState> {
// 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);
}
}