Draw the editor's mouse pointer from SVG files, on screen too
game-maker / gate (push) Successful in 4m19s
game-maker / suite (push) Failing after 17m35s

The pointer drawn into an editor recording lived as paths in code, so
it could not be looked at or edited by hand. The shapes are now SVG
files, and the editor can show the same pointer on screen, so what is
recorded is what was seen.

- src/core/recording/cursors/ holds one file per shape: arrow, hand,
  open-hand, fist, move, crosshair, text and resize. Resize is turned
  for the vertical and diagonal directions.
- data-hot="x y" on the <svg> is the hotspot in viewBox units. Width
  and height in the file are ignored, and anything before <svg>, such
  as <?xml ...> from a drawing program, is cut off.
- The open hand had six fingers. It has a thumb and four now, the
  middle one longest.
- CURSOR_SIZE = 0.8 scales every pointer the same on screen and in a
  recording, so the files keep their units.
- General settings has "Custom mouse pointer", on by default. It hangs
  the files into Pixi's cursorStyles as CSS cursors with the keyword as
  fallback, so the system still draws the pointer and the canvas does
  not. Off gives Pixi's own values back at once.
- A recording draws the pointer from the same files at its own size,
  so it stays sharp in 4K. It still reads the shape from the canvas
  when that holds a url(...) cursor.

Tests:
- Unit recording and view-tween: 3 new, green. They read viewBox and
  data-hot in an edited form, the boxes of turned shapes, the default
  size and old settings without the switch. Red without data-hot and
  without cutting before <svg>.
- e2e 46i-ii: the canvas cursor is a url(...) ending in default and
  the image loads. Off gives inherit and stores false, on brings the
  file back. Red without useCursorFiles.
- e2e 67e: the recording is still black at the mouse position.
- The README names 2046 e2e checks.
- Gate on melektaus-atx: 304 files, 4702 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-12 17:33:57 +02:00
1 parent ba01d146e9
commit d4fc425233
19 files changed
+398 -63

No files matched your search

+2
View File
@@ -54,6 +54,7 @@ import type { ProjectSettingsDialog } from './ProjectSettingsDialog';
import { dialogSlot } from './FormDialog';
import { generalSettings, keepGeneralSettings } from './generalSettings';
import { GeneralSettingsDialog } from './GeneralSettingsDialog';
import { useCursorFiles } from './editorCursors';
import { ContainerEditor } from './container/ContainerEditor';
import { CompositionEditor } from './composition/CompositionEditor';
import { EditorRun } from './EditorRun';
@@ -428,6 +429,7 @@ export class Editor implements PlayHost {
showBadge: (on) => this.toolbar.showRecording(on),
badgeRoom: () => this.toolbar.recordingRoom(),
});
useCursorFiles(app.renderer.events, app.canvas);
this.play = new PlaySession(state, this);
this.editorRun = new EditorRun(state, {
// The game view is the one place where no key means anything to the editor, so that is where
+44 -15
View File
@@ -1,5 +1,11 @@
import { UPDATE_PRIORITY } from 'pixi.js';
import { cursorShape, type CursorShape } from '../core/recording/cursors';
import {
CURSOR_SIZE,
cursorShape,
cursorShapes,
cursorSvgAt,
type CursorShape,
} from '../core/recording/cursors';
import { editorRecordingPlan, type EditorRecordingSettings } from '../core/recording/editorRecording';
import { elapsedText, fitPicture, recordingName } from '../core/recording/recording';
import { renderCount, requestRender, Theme, toHex, ui } from '../ui';
@@ -216,7 +222,8 @@ class CanvasSource implements FrameSource {
private readonly frame: OffscreenCanvas;
private readonly framePen: OffscreenCanvasRenderingContext2D;
private readonly pointer = new PointerTrack();
private readonly paths = new Map<CursorShape, Path2D>();
/** Each cursor file drawn at the size of the recording, once it has loaded. */
private readonly cursors = new Map<CursorShape, { scale: number; image: HTMLImageElement | null }>();
private copied = false;
private ready: VideoFrame[] = [];
@@ -230,7 +237,10 @@ class CanvasSource implements FrameSource {
this.picturePen = this.picture.getContext('2d', { alpha: false })!;
this.frame = new OffscreenCanvas(this.size.w, this.size.h);
this.framePen = this.frame.getContext('2d', { alpha: false })!;
if (showsPointer) this.pointer.attach(canvas);
if (showsPointer) {
this.pointer.attach(canvas);
for (const shape of cursorShapes()) this.cursorAt(shape, this.pointerScale());
}
}
open(): { w: number; h: number } {
@@ -281,25 +291,44 @@ class CanvasSource implements FrameSource {
const at = fitPicture({ w: canvas.width, h: canvas.height }, this.size);
const x = at.x + ((pointer.x - rect.left) / rect.width) * at.w;
const y = at.y + ((pointer.y - rect.top) / rect.height) * at.h;
// Against the layout size: a canvas shrunk to fit (`?w=&h=`) still records a full-size pointer.
const scale = at.w / ui().app.screen.width;
let path = this.paths.get(shape);
if (!path) this.paths.set(shape, (path = new Path2D(shape.path)));
const scale = this.pointerScale();
const image = this.cursorAt(shape, scale);
if (!image) return;
const { box, hot } = shape;
const pen = this.framePen;
pen.save();
pen.translate(x, y);
pen.scale(scale, scale);
pen.rotate((shape.turn * Math.PI) / 180);
pen.translate(-shape.hot.x, -shape.hot.y);
pen.lineJoin = 'round';
pen.lineWidth = 2;
pen.strokeStyle = '#fff';
pen.stroke(path);
pen.fillStyle = '#000';
pen.fill(path);
pen.drawImage(image, (box.x - hot.x) * scale, (box.y - hot.y) * scale, box.w * scale, box.h * scale);
pen.restore();
}
/**
* Recording pixels per unit of a cursor file: the size the editor shows it at, in CSS pixels of
* the layout, so a canvas shrunk to fit (`?w=&h=`) still records a full-size pointer.
*/
private pointerScale(): number {
const at = fitPicture({ w: this.canvas.width, h: this.canvas.height }, this.size);
return (at.w / ui().app.screen.width) * CURSOR_SIZE;
}
/** The cursor file drawn at `scale`, null while it loads; a new scale loads it anew. */
private cursorAt(shape: CursorShape, scale: number): HTMLImageElement | null {
const known = this.cursors.get(shape);
if (known?.scale === scale) return known.image;
const entry: { scale: number; image: HTMLImageElement | null } = { scale, image: known?.image ?? null };
this.cursors.set(shape, entry);
const image = new Image();
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(cursorSvgAt(shape, scale))}`;
image.decode().then(
() => {
if (this.cursors.get(shape) === entry) entry.image = image;
},
() => console.warn('[recording] a cursor SVG does not load', shape.svg.slice(0, 120)),
);
return entry.image;
}
collect(encode: (frame: VideoFrame) => void): void {
for (const frame of this.ready.splice(0)) encode(frame);
}
+21 -1
View File
@@ -11,6 +11,8 @@ export class GeneralSettingsDialog extends FormDialog {
private readonly zoomField: NumberField;
private showEmpty: boolean;
private readonly emptyBox: Checkbox;
private customCursor: boolean;
private readonly cursorBox: Checkbox;
constructor(settings: GeneralSettings, onApply: (settings: GeneralSettings) => void) {
super(340, 'General settings');
@@ -38,12 +40,25 @@ export class GeneralSettingsDialog extends FormDialog {
this.showEmpty = value;
};
this.content.add(this.formRow(this.emptyBox, new Label('Show empty asset categories and types')));
this.customCursor = settings.customCursor;
this.cursorBox = new Checkbox(this.customCursor);
this.cursorBox.onChange = (value) => {
this.customCursor = value;
};
this.content.add(this.formRow(this.cursorBox, new Label('Custom mouse pointer')));
this.content.add(
new Label("The pointer an editor recording draws. Off shows the system's.", { style: 'muted' }),
);
this.addButtons('Apply', 'primary', () => onApply(this.values()));
}
/** The settings as the fields stand (test hook). */
values(): GeneralSettings {
return { zoomTransitionMs: zoomTransitionMs(this.zoomMs), showEmptyAssetGroups: this.showEmpty };
return {
zoomTransitionMs: zoomTransitionMs(this.zoomMs),
showEmptyAssetGroups: this.showEmpty,
customCursor: this.customCursor,
};
}
/** The switch for the empty asset groups, so a check can click it. */
@@ -51,6 +66,11 @@ export class GeneralSettingsDialog extends FormDialog {
return this.emptyBox;
}
/** The switch for the custom mouse pointer, so a check can click it. */
get customCursorControl(): Checkbox {
return this.cursorBox;
}
/** The zoom field itself, so a check can type into it. */
get zoomControl(): NumberField {
return this.zoomField;
+7
View File
@@ -365,6 +365,13 @@ export function uiHooks({ app, state, editor }: HookContext) {
const dialog = editor.generalSettingsDialog();
return dialog ? centreOf(dialog.emptyGroupsControl) : null;
},
/** Where to click the switch for the custom mouse pointer inside that dialog. */
generalSettingsCustomCursorAt: () => {
const dialog = editor.generalSettingsDialog();
return dialog ? centreOf(dialog.customCursorControl) : null;
},
/** The CSS cursor the editor's canvas shows right now. */
canvasCursor: () => ui().app.canvas.style.cursor,
/**
* The zoom transition in ms as kept in this browser; with `ms` it is set first. The harness sets
* 0 on every load, so a check reading the zoom after the wheel finds the step and not the way.
+24
View File
@@ -0,0 +1,24 @@
import type { EventSystem } from 'pixi.js';
import { cursorCss, cursorKeyword, cursorKeywords } from '../core/recording/cursors';
import { generalSettings, onGeneralSettings } from './generalSettings';
/**
* The editor's mouse pointer from the files an editor recording draws (`core/recording/cursors/`),
* as the canvas's CSS cursor: the system still draws it, the canvas never does. General settings
* switch it off, and the pointer goes back to what Pixi sets by itself. Returns the way to stop.
*/
export function useCursorFiles(events: EventSystem, canvas: HTMLCanvasElement): () => void {
const own = { ...events.cursorStyles };
const files = Object.fromEntries(cursorKeywords().map((keyword) => [keyword, cursorCss(keyword)]));
const apply = (): void => {
const styles = events.cursorStyles;
for (const keyword of Object.keys(styles)) delete styles[keyword];
Object.assign(styles, own, generalSettings().customCursor ? files : {});
// Pixi sets a style only when the pointer's mode changes: the one showing now changes here.
const keyword = cursorKeyword(canvas.style.cursor);
const style = styles[keyword];
canvas.style.cursor = typeof style === 'string' ? style : keyword;
};
apply();
return onGeneralSettings(apply);
}
+4 -1
View File
@@ -12,11 +12,13 @@ export interface GeneralSettings {
zoomTransitionMs: number;
/** Whether the asset panel lists every category and type, faint where it holds no asset (#361). */
showEmptyAssetGroups: boolean;
/** Whether the editor shows the mouse pointer from the recording's files rather than the system's. */
customCursor: boolean;
}
export const ZOOM_TRANSITION_MAX_MS = 1000;
const DEFAULTS: GeneralSettings = { zoomTransitionMs: 200, showEmptyAssetGroups: false };
const DEFAULTS: GeneralSettings = { zoomTransitionMs: 200, showEmptyAssetGroups: false, customCursor: true };
let settings: GeneralSettings | null = null;
const listeners = new Set<() => void>();
@@ -43,6 +45,7 @@ function read(): GeneralSettings {
return {
zoomTransitionMs: zoomTransitionMs(raw.zoomTransitionMs),
showEmptyAssetGroups: raw.showEmptyAssetGroups === true,
customCursor: raw.customCursor !== false,
};
} catch {
return { ...DEFAULTS };