game-maker / suite (push) Successful in 10m46s
The renderer is taken off the ticker; the ticker itself keeps running, so the clock, a live world and the panels still get their step. Drawing now happens inside a 200 ms trailing window that every pointer, wheel and key event, every Widget.invalidate/resize, every Stack.relayout, every EditorState.emit and both library onChange hooks reopen. The window rather than a single frame is what makes the ~70 direct redraw() calls that never pass through invalidate() safe, along with repaints an event only starts: a microtask, a texture upload, a resolved font. Editor.movesByItself() keeps asking for every frame while Play or an editor run is on, a time shader is on screen, or the particle or animation preview is showing. The FPS counter now counts drawn frames over the time the renderer was awake. Idle seconds are not slow seconds, so it answers "how smooth while it moves" and holds its last answer instead of falling to 0 at rest. Measured on the built stand, 20 s per variant, CPU of all chromium processes from /proc: kept awake 75.5 % of a core at 60 fps, render on change 8.8 % with 0 frames drawn, awake again 83.9 % as an order probe. No colour check can see a missing repaint: pixelAt extracts from the stage on demand and is just as green on a screen frozen a minute ago. Step 33 watches the new __gm.renderStats() counter instead.
122 lines
3.9 KiB
TypeScript
122 lines
3.9 KiB
TypeScript
import { requestRender } from '../render';
|
|
import { type Size, Widget } from '../Widget';
|
|
|
|
export interface StackOptions {
|
|
gap?: number;
|
|
padding?: number;
|
|
/** Cross-axis alignment of children that are smaller than the stack. */
|
|
align?: 'stretch' | 'start' | 'center' | 'end';
|
|
}
|
|
|
|
/**
|
|
* Lays out child widgets in a row or column. Children with `flex > 0` share, weighted, the space
|
|
* left after the fixed ones took their measured size. Non-widget children are still rendered.
|
|
*/
|
|
export abstract class Stack extends Widget {
|
|
gap: number;
|
|
padding: number;
|
|
align: NonNullable<StackOptions['align']>;
|
|
|
|
constructor(
|
|
protected readonly axis: 'x' | 'y',
|
|
options: StackOptions = {},
|
|
) {
|
|
super();
|
|
this.gap = options.gap ?? 0;
|
|
this.padding = options.padding ?? 0;
|
|
this.align = options.align ?? 'stretch';
|
|
}
|
|
|
|
/** The children space is distributed among: visible widgets, in child order. */
|
|
get items(): Widget[] {
|
|
return this.children.filter((c): c is Widget => c instanceof Widget && c.visible);
|
|
}
|
|
|
|
/** Does not lay out; call `relayout()` once the stack is sized. */
|
|
add<T extends Widget>(widget: T, flex = 0): T {
|
|
widget.flex = flex;
|
|
this.addChild(widget);
|
|
return widget;
|
|
}
|
|
|
|
override measure(): Size {
|
|
let main = 0;
|
|
let cross = 0;
|
|
const items = this.items;
|
|
for (const item of items) {
|
|
const size = item.measure();
|
|
main += this.axis === 'x' ? size.w : size.h;
|
|
cross = Math.max(cross, this.axis === 'x' ? size.h : size.w);
|
|
}
|
|
main += Math.max(0, items.length - 1) * this.gap + this.padding * 2;
|
|
cross += this.padding * 2;
|
|
const w = this.axis === 'x' ? main : cross;
|
|
const h = this.axis === 'x' ? cross : main;
|
|
return { w: Math.max(w, this.minWidth), h: Math.max(h, this.minHeight) };
|
|
}
|
|
|
|
override layout(): void {
|
|
const items = this.items;
|
|
const mainTotal = (this.axis === 'x' ? this.w : this.h) - this.padding * 2;
|
|
const crossTotal = (this.axis === 'x' ? this.h : this.w) - this.padding * 2;
|
|
const sizes = items.map((item) => item.measure());
|
|
let fixed = Math.max(0, items.length - 1) * this.gap;
|
|
let flexSum = 0;
|
|
items.forEach((item, i) => {
|
|
if (item.flex > 0) flexSum += item.flex;
|
|
else fixed += this.axis === 'x' ? sizes[i]!.w : sizes[i]!.h;
|
|
});
|
|
const free = Math.max(0, mainTotal - fixed);
|
|
|
|
let cursor = this.padding;
|
|
items.forEach((item, i) => {
|
|
const size = sizes[i]!;
|
|
const mainSize = item.flex > 0 ? (free * item.flex) / flexSum : this.axis === 'x' ? size.w : size.h;
|
|
const preferredCross = this.axis === 'x' ? size.h : size.w;
|
|
const crossSize = this.align === 'stretch' ? crossTotal : Math.min(preferredCross, crossTotal);
|
|
let crossPos = this.padding;
|
|
if (this.align === 'center') crossPos += (crossTotal - crossSize) / 2;
|
|
else if (this.align === 'end') crossPos += crossTotal - crossSize;
|
|
|
|
if (this.axis === 'x') {
|
|
item.position.set(Math.round(cursor), Math.round(crossPos));
|
|
item.resize(Math.round(mainSize), Math.round(crossSize));
|
|
} else {
|
|
item.position.set(Math.round(crossPos), Math.round(cursor));
|
|
item.resize(Math.round(crossSize), Math.round(mainSize));
|
|
}
|
|
cursor += mainSize + this.gap;
|
|
});
|
|
}
|
|
|
|
/** Re-run the layout with the current size (after children were added/removed/hidden). */
|
|
relayout(): void {
|
|
this.layout();
|
|
this.redraw();
|
|
// A stack that only moves children around resizes none of them, so this is its own wake-up.
|
|
requestRender();
|
|
}
|
|
}
|
|
|
|
export class VStack extends Stack {
|
|
constructor(options: StackOptions = {}) {
|
|
super('y', options);
|
|
}
|
|
}
|
|
|
|
export class HStack extends Stack {
|
|
constructor(options: StackOptions = {}) {
|
|
super('x', options);
|
|
}
|
|
}
|
|
|
|
/** Empty widget used as a flexible spacer or fixed-size gap. */
|
|
export class Spacer extends Widget {
|
|
constructor(size = 0) {
|
|
super();
|
|
this.minWidth = size;
|
|
this.minHeight = size;
|
|
this.eventMode = 'none';
|
|
}
|
|
}
|