Add zoom steps and a larger start caption to the game link (#266)
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Failing after 12m45s

The released game only ever zoomed in whole steps up to 400%, so a
small camera on a large screen stayed far smaller than it could be.
The "Click to play" caption was drawn in the 11 px panel title size
and was easy to miss.

- GAME_SCALE_STEPS gains 300%, 500% and 600%. The game tab picker,
  Auto in the editor and the game link all read the same list.
- The start caption takes its size from the new Theme.captionSize
  (16) instead of a constant in GameScreen.
- The player page keeps the default arrow cursor at all times. It no
  longer switches to the hand over the caption.

Tests:
- view.test: Auto for 320x180 in 2000x2000 is now 6, with cases
  for 3 and 5. play-settings.test takes 2.5 and 8 as invalid steps.
- e2e 60 checks the caption against the panel title size and the
  arrow cursor before and after the start. Module 03 follows the new
  step list.
- ./check.sh 2808 tests, CHECK EXIT 0 (before the last Theme tweak,
  which changes no test). Full e2e run ALL PASSED, 1574 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 14:14:54 +02:00
1 parent 4194f3e150
commit 24d360aeec
8 files changed
+40 -9

No files matched your search

+1 -1
View File
@@ -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, 1572 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1574 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
+1 -1
View File
@@ -12,7 +12,7 @@ export const DEFAULT_PLAY_FROM: PlayFrom = 'start';
/** How the game view sizes the camera frame: a fixed factor, the largest step that fits, or fitted. */ /** How the game view sizes the camera frame: a fixed factor, the largest step that fits, or fitted. */
export type GameScale = 'auto' | 'fit' | number; export type GameScale = 'auto' | 'fit' | number;
export const GAME_SCALE_STEPS = [0.25, 0.5, 1, 2, 4]; export const GAME_SCALE_STEPS = [0.25, 0.5, 1, 2, 3, 4, 5, 6];
export const DEFAULT_GAME_SCALE: GameScale = 1; export const DEFAULT_GAME_SCALE: GameScale = 1;
+13 -3
View File
@@ -1,4 +1,4 @@
import { Text } from 'pixi.js'; 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';
@@ -12,8 +12,16 @@ import { type Frame, type ViewSource, WorldViewport } from '../viewport/WorldVie
* zoom step that fits (Auto), letterboxed in the page colour. Before the game runs, and after it * zoom step that fits (Auto), letterboxed in the page colour. Before the game runs, and after it
* stopped itself, it shows a line of text instead – "Click to play", or why there is nothing to play. * stopped itself, it shows a line of text instead – "Click to play", or why there is nothing to play.
*/ */
function captionStyle(): TextStyle {
const style = textStyle('title').clone();
style.fontSize = Theme.captionSize;
style.letterSpacing = 1;
return style;
}
export class GameScreen extends WorldViewport { export class GameScreen extends WorldViewport {
private readonly caption = new Text({ text: '', style: textStyle('title') }); private readonly caption = new Text({ text: '', style: captionStyle() });
private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') }); private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') });
private running: World | null = null; private running: World | null = null;
/** Asked when the caption is clicked while it offers to start. */ /** Asked when the caption is clicked while it offers to start. */
@@ -41,7 +49,6 @@ export class GameScreen extends WorldViewport {
say(text: string, startable = false): void { say(text: string, startable = false): void {
this.caption.text = text; this.caption.text = text;
this.startable = startable; this.startable = startable;
this.cursor = startable ? 'pointer' : 'default';
this.redraw(); this.redraw();
} }
@@ -61,6 +68,8 @@ export class GameScreen extends WorldViewport {
*/ */
info(): { info(): {
caption: string | null; caption: string | null;
/** How tall the caption is drawn, in CSS pixels. */
captionHeight: number;
scale: number | null; scale: number | null;
frame: { x: number; y: number; w: number; h: number } | null; frame: { x: number; y: number; w: number; h: number } | null;
} { } {
@@ -68,6 +77,7 @@ export class GameScreen extends WorldViewport {
const origin = this.getGlobalPosition(); const origin = this.getGlobalPosition();
return { return {
caption: this.running ? null : this.caption.text, caption: this.running ? null : this.caption.text,
captionHeight: Math.round(this.caption.height),
scale: view?.scale ?? null, scale: view?.scale ?? null,
frame: view ? { ...view.rect, x: origin.x + view.rect.x, y: origin.y + view.rect.y } : null, frame: view ? { ...view.rect, x: origin.x + view.rect.x, y: origin.y + view.rect.y } : null,
}; };
+1
View File
@@ -8,6 +8,7 @@ export const Theme = {
fontSize: 13, fontSize: 13,
smallFontSize: 11, smallFontSize: 11,
viewportLabelSize: 10, viewportLabelSize: 10,
captionSize: 16,
rowHeight: 24, rowHeight: 24,
controlHeight: 24, controlHeight: 24,
padding: 8, padding: 8,
+4 -1
View File
@@ -40,7 +40,10 @@ describe('play settings in the project', () => {
expect(playFromOf(null)).toBe('start'); expect(playFromOf(null)).toBe('start');
expect(gameScaleOf({ gameScale: 'fit' })).toBe('fit'); expect(gameScaleOf({ gameScale: 'fit' })).toBe('fit');
expect(gameScaleOf({ gameScale: 0.25 })).toBe(0.25); expect(gameScaleOf({ gameScale: 0.25 })).toBe(0.25);
expect(gameScaleOf({ gameScale: 3 })).toBe(1); expect(gameScaleOf({ gameScale: 3 })).toBe(3);
expect(gameScaleOf({ gameScale: 6 })).toBe(6);
expect(gameScaleOf({ gameScale: 2.5 })).toBe(1);
expect(gameScaleOf({ gameScale: 8 })).toBe(1);
expect(gameScaleOf({ gameScale: '2' })).toBe(1); expect(gameScaleOf({ gameScale: '2' })).toBe(1);
}); });
+3 -1
View File
@@ -206,7 +206,9 @@ describe('gameScaleFactor', () => {
expect(gameScaleFactor('auto', 640, 360, 800, 600)).toBe(1); expect(gameScaleFactor('auto', 640, 360, 800, 600)).toBe(1);
expect(gameScaleFactor('auto', 640, 360, 1280, 720)).toBe(2); expect(gameScaleFactor('auto', 640, 360, 1280, 720)).toBe(2);
expect(gameScaleFactor('auto', 640, 360, 639, 600)).toBe(0.5); expect(gameScaleFactor('auto', 640, 360, 639, 600)).toBe(0.5);
expect(gameScaleFactor('auto', 320, 180, 2000, 2000)).toBe(4); expect(gameScaleFactor('auto', 320, 180, 1000, 800)).toBe(3);
expect(gameScaleFactor('auto', 320, 180, 1700, 1000)).toBe(5);
expect(gameScaleFactor('auto', 320, 180, 2000, 2000)).toBe(6);
}); });
it('auto falls back to the smallest step when not even that fits', () => { it('auto falls back to the smallest step when not even that fits', () => {
+1 -1
View File
@@ -355,7 +355,7 @@ export async function play(t) {
JSON.stringify(g), JSON.stringify(g),
); );
g = await pickScale('Auto'); g = await pickScale('Auto');
const auto = Math.max(0.25, ...[0.25, 0.5, 1, 2, 4].filter((step) => step <= fit)); const auto = Math.max(0.25, ...[0.25, 0.5, 1, 2, 3, 4, 5, 6].filter((step) => step <= fit));
check( check(
'auto takes the largest step that still fits', 'auto takes the largest step that still fits',
g.mode === 'auto' && g.factor === auto && g.label === `Auto · ${auto * 100}%` && g.frame.w === 640 * auto, g.mode === 'auto' && g.factor === auto && g.label === `Auto · ${auto * 100}%` && g.frame.w === 640 * auto,
+16 -1
View File
@@ -7,7 +7,7 @@ const UPDATED = 'Release 60 updated';
// Not a 16:9 multiple of the window on purpose: fitting it would need a scale between the steps. // Not a 16:9 multiple of the window on purpose: fitting it would need a scale between the steps.
const CAMERA = { width: 300, height: 180 }; const CAMERA = { width: 300, height: 180 };
/** The zoom steps of the game view (`GAME_SCALE_STEPS`); Auto takes the largest that fits whole. */ /** The zoom steps of the game view (`GAME_SCALE_STEPS`); Auto takes the largest that fits whole. */
const STEPS = [0.25, 0.5, 1, 2, 4]; const STEPS = [0.25, 0.5, 1, 2, 3, 4, 5, 6];
const RED = '#e0553f'; const RED = '#e0553f';
const BACKGROUND = '#22252c'; const BACKGROUND = '#22252c';
@@ -206,6 +206,8 @@ export async function release(t) {
title: await json('document.title'), title: await json('document.title'),
editor: await json('typeof window.__gm'), editor: await json('typeof window.__gm'),
running: await player('running()'), running: await player('running()'),
captionHeight: (await player('screen()')).captionHeight,
cursor: await json(`getComputedStyle(document.querySelector('canvas')).cursor`),
}; };
check( check(
'the link opens without a session: only the game, on the page colour, waiting for a click', 'the link opens without a session: only the game, on the page colour, waiting for a click',
@@ -215,6 +217,13 @@ export async function release(t) {
page.running === false, page.running === false,
JSON.stringify(page), JSON.stringify(page),
); );
// The panel titles are 11 px; the caption is set at Theme.captionSize (16). The page keeps the
// arrow throughout – Pixi writes `default` as `inherit`, so read what the browser makes of it.
check(
'"Click to play" is set larger than the panel titles, under the arrow',
page.captionHeight >= 16 && ['auto', 'default'].includes(page.cursor),
JSON.stringify(page),
);
// 60d. A click starts it in the start scene, zoomed to Auto: a whole number of pixels per unit. // 60d. A click starts it in the start scene, zoomed to Auto: a whole number of pixels per unit.
const size = { w: await json('innerWidth'), h: await json('innerHeight') }; const size = { w: await json('innerWidth'), h: await json('innerHeight') };
@@ -240,6 +249,12 @@ export async function release(t) {
JSON.stringify({ size, auto, screen, pixels, log: await player('log()') }), JSON.stringify({ size, auto, screen, pixels, log: await player('log()') }),
); );
await shot('60-release-playing'); await shot('60-release-playing');
const playingCursor = await json(`getComputedStyle(document.querySelector('canvas')).cursor`);
check(
'over a running game the cursor is still the arrow',
['auto', 'default'].includes(playingCursor),
playingCursor,
);
// 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);