diff --git a/README.md b/README.md index 70030d51..c1f5020b 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, 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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/model/playSettings.ts b/src/core/model/playSettings.ts index 13c3721f..7790c905 100644 --- a/src/core/model/playSettings.ts +++ b/src/core/model/playSettings.ts @@ -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. */ 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; diff --git a/src/player/GameScreen.ts b/src/player/GameScreen.ts index 3f92081f..56ee4b12 100644 --- a/src/player/GameScreen.ts +++ b/src/player/GameScreen.ts @@ -1,4 +1,4 @@ -import { Text } from 'pixi.js'; +import { Text, type TextStyle } from 'pixi.js'; import type { World } from '../runtime/World'; import { Theme, textStyle, ui } from '../ui'; 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 * 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 { - 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 running: World | null = null; /** 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 { this.caption.text = text; this.startable = startable; - this.cursor = startable ? 'pointer' : 'default'; this.redraw(); } @@ -61,6 +68,8 @@ export class GameScreen extends WorldViewport { */ info(): { caption: string | null; + /** How tall the caption is drawn, in CSS pixels. */ + captionHeight: number; scale: 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(); return { caption: this.running ? null : this.caption.text, + captionHeight: Math.round(this.caption.height), scale: view?.scale ?? null, frame: view ? { ...view.rect, x: origin.x + view.rect.x, y: origin.y + view.rect.y } : null, }; diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 37ea4f42..946af131 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -8,6 +8,7 @@ export const Theme = { fontSize: 13, smallFontSize: 11, viewportLabelSize: 10, + captionSize: 16, rowHeight: 24, controlHeight: 24, padding: 8, diff --git a/tests/editor/play-settings.test.ts b/tests/editor/play-settings.test.ts index e15418bc..375f16fc 100644 --- a/tests/editor/play-settings.test.ts +++ b/tests/editor/play-settings.test.ts @@ -40,7 +40,10 @@ describe('play settings in the project', () => { expect(playFromOf(null)).toBe('start'); expect(gameScaleOf({ gameScale: 'fit' })).toBe('fit'); 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); }); diff --git a/tests/viewport/view.test.ts b/tests/viewport/view.test.ts index 8703a1f2..46ab14a3 100644 --- a/tests/viewport/view.test.ts +++ b/tests/viewport/view.test.ts @@ -206,7 +206,9 @@ describe('gameScaleFactor', () => { expect(gameScaleFactor('auto', 640, 360, 800, 600)).toBe(1); expect(gameScaleFactor('auto', 640, 360, 1280, 720)).toBe(2); 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', () => { diff --git a/tools/e2e/03-play.mjs b/tools/e2e/03-play.mjs index 5f29d34a..ce6af1a5 100644 --- a/tools/e2e/03-play.mjs +++ b/tools/e2e/03-play.mjs @@ -355,7 +355,7 @@ export async function play(t) { JSON.stringify(g), ); 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( 'auto takes the largest step that still fits', g.mode === 'auto' && g.factor === auto && g.label === `Auto ยท ${auto * 100}%` && g.frame.w === 640 * auto, diff --git a/tools/e2e/60-release.mjs b/tools/e2e/60-release.mjs index c004b8cb..818a3962 100644 --- a/tools/e2e/60-release.mjs +++ b/tools/e2e/60-release.mjs @@ -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. const CAMERA = { width: 300, height: 180 }; /** 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 BACKGROUND = '#22252c'; @@ -206,6 +206,8 @@ export async function release(t) { title: await json('document.title'), editor: await json('typeof window.__gm'), running: await player('running()'), + captionHeight: (await player('screen()')).captionHeight, + cursor: await json(`getComputedStyle(document.querySelector('canvas')).cursor`), }; check( '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, 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. 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()') }), ); 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. await holdKey('ArrowRight', 500);