Reimplement the archived UI widget stack on the new element-tree system
The old widget system (archive/dusk/ui/) depended on infrastructure this rewrite deleted -- a static X-macro element list, a 9-slice UI_FRAME, and a global gamepad/keyboard focus stack -- so everything below is rebuilt on top of the new native uielement_t pool + parent/child tree instead, gamepad/keyboard-only (no mouse support exists in the input system). New JS widgets (assets/scripts/), all pure composites of Label/Rectangle following the Button.js pattern: Checkbox, Tab, Slider, Dropdown. Slider/ Dropdown expose a directionInput(dx, dy) hook so a menu can hand them LEFT/RIGHT before falling back to cursor movement. New Menu.js replaces the old global uifocus_t stack: a small push/pop stack of Menu instances where only the topmost consumes CANCEL/ACCEPT/ direction input each tick (needed for nested modals -- a Settings sub-page menu plus a "discard changes?" Confirm can be open at once). Held-direction repeat timing matches the old 0.5s delay / 0.1s repeat. New overlay composites (added via a new UI.addOverlay()/UI.removeOverlay(), always drawn/updated after normal roots): FpsCounter, ConsoleOverlay (one Label per console history line, driven directly rather than via add() since History (16) exceeds the 8-children-per-element cap), Crop (letterbox/pillarbox bars), and Fullbox (a reusable tweened full-screen fade covering both the old fullbox and transition effects -- reuses shared instances rather than allocate-and-dispose, since disposing an element from inside its own render() callback risks the same JerryScript refcount corruption hit earlier in this rewrite). New Confirm.js (Yes/No dialog) and Settings.js (+ SettingsGeneral/ SettingsInput sub-pages) built on Menu. Display/Audio settings pages are intentionally not ported -- neither had a real backing engine system even in the old code. Frame is a flat Rectangle for now, not real 9-slice (a genuinely bigger native lift, deliberately deferred). Small native binding additions needed by the above, each with its own unit test: Time.renderDelta, Console.lineCount/getLine/visible/ consumeDirty, a new Screen module, a new Locale module, Input.deadzone, a new Save module, and Label.color (was missing entirely). Also fixes a real gap from the UI.addOverlay() work: uiElementDispose() wasn't removing disposed elements from the new overlay root array, which would have left dangling ids behind. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
// Gamepad/keyboard-only for now -- no mouse support exists in the input
|
||||
// system yet. Something else (e.g. a future Menu widget) is responsible
|
||||
// for moving `highlighted` between multiple buttons; a Button only knows
|
||||
// how to fire onSelect() when ACCEPT is pressed while it's the highlighted
|
||||
// one.
|
||||
class Button extends UIElement {
|
||||
init() {
|
||||
this.background = new Rectangle();
|
||||
this.background.width = 120;
|
||||
this.background.height = 32;
|
||||
this.background.color = Button.COLOR_NORMAL;
|
||||
this.add(this.background);
|
||||
|
||||
this.label = new Label();
|
||||
this.label.x = 8;
|
||||
this.label.y = 8;
|
||||
this.add(this.label);
|
||||
|
||||
this.highlighted = false;
|
||||
this.onSelect = null;
|
||||
}
|
||||
|
||||
get text() { return this.label.text; }
|
||||
set text(value) { this.label.text = value; }
|
||||
|
||||
setHighlighted(highlighted) {
|
||||
this.highlighted = highlighted;
|
||||
this.background.color = highlighted
|
||||
? Button.COLOR_HIGHLIGHTED
|
||||
: Button.COLOR_NORMAL;
|
||||
}
|
||||
|
||||
update() {
|
||||
this.updateChildren();
|
||||
if(this.highlighted && Input.pressed(InputBind.ACCEPT) && this.onSelect) {
|
||||
this.onSelect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button.COLOR_NORMAL = { r: 255, g: 255, b: 255, a: 255 };
|
||||
Button.COLOR_HIGHLIGHTED = { r: 255, g: 0, b: 0, a: 255 };
|
||||
|
||||
module.exports = Button;
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
// A single label showing "Y "/"N " + the caller's text -- no background,
|
||||
// matching the old widget exactly. Toggling itself (checked) is driven
|
||||
// externally, typically by a Menu handing it LEFT/RIGHT via
|
||||
// directionInput() while this checkbox is the highlighted item.
|
||||
class Checkbox extends UIElement {
|
||||
init() {
|
||||
this.label = new Label();
|
||||
this.add(this.label);
|
||||
|
||||
this._text = '';
|
||||
this._checked = false;
|
||||
this.highlighted = false;
|
||||
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
get text() { return this._text; }
|
||||
set text(value) {
|
||||
this._text = value;
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
get checked() { return this._checked; }
|
||||
set checked(value) {
|
||||
this._checked = value;
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.checked = !this.checked;
|
||||
}
|
||||
|
||||
setHighlighted(highlighted) {
|
||||
this.highlighted = highlighted;
|
||||
this.label.color = highlighted
|
||||
? Checkbox.COLOR_HIGHLIGHTED
|
||||
: Checkbox.COLOR_NORMAL;
|
||||
}
|
||||
|
||||
// Menu direction-input opt-out hook: LEFT/RIGHT toggles and consumes
|
||||
// the input; UP/DOWN falls through to default cursor movement.
|
||||
directionInput(dx, dy) {
|
||||
if(dx === 0) return false;
|
||||
this.toggle();
|
||||
return true;
|
||||
}
|
||||
|
||||
_refresh() {
|
||||
this.label.text = (this._checked ? 'Y ' : 'N ') + this._text;
|
||||
}
|
||||
}
|
||||
|
||||
Checkbox.COLOR_NORMAL = { r: 255, g: 255, b: 255, a: 255 };
|
||||
Checkbox.COLOR_HIGHLIGHTED = { r: 255, g: 0, b: 0, a: 255 };
|
||||
|
||||
module.exports = Checkbox;
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
var Menu = require('./Menu.js');
|
||||
var Button = require('./Button.js');
|
||||
|
||||
// A Yes/No dialog: dim full-screen backdrop + a flat-color panel (see
|
||||
// Fullbox.js/plan notes on why there's no real 9-slice Frame yet) +
|
||||
// text + a 2-item Menu. Confirm.open(text, callback) lazily creates and
|
||||
// reuses ONE shared instance rather than allocating fresh every time --
|
||||
// the Menu stack already guarantees at most one confirm is ever
|
||||
// meaningfully active, so there's nothing to gain from a fresh instance
|
||||
// per call, and it sidesteps ever needing to dispose one (see Fullbox.js
|
||||
// for why disposing from inside your own callback chain is risky).
|
||||
class Confirm extends UIElement {
|
||||
init() {
|
||||
this.backdrop = new Rectangle();
|
||||
this.backdrop.color = Confirm.COLOR_BACKDROP;
|
||||
this.add(this.backdrop);
|
||||
|
||||
this.panel = new Rectangle();
|
||||
this.panel.width = Confirm.PANEL_WIDTH;
|
||||
this.panel.height = Confirm.PANEL_HEIGHT;
|
||||
this.panel.color = Confirm.COLOR_PANEL;
|
||||
this.add(this.panel);
|
||||
|
||||
this.label = new Label();
|
||||
this.add(this.label);
|
||||
|
||||
this.menu = new Menu();
|
||||
this.menu.cols = 2;
|
||||
this.add(this.menu);
|
||||
|
||||
this.confirmButton = new Button();
|
||||
this.confirmButton.text = 'Confirm';
|
||||
this.menu.addItem(this.confirmButton);
|
||||
|
||||
this.cancelButton = new Button();
|
||||
this.cancelButton.text = 'Cancel';
|
||||
this.menu.addItem(this.cancelButton);
|
||||
|
||||
this.menu.onSelected = (index) => this._finish(index === 0);
|
||||
// CANCEL (the bind, not the button) also closes the menu without
|
||||
// firing onSelected -- treat that the same as picking Cancel.
|
||||
this.menu.onClosed = () => this._finish(false);
|
||||
|
||||
this._callback = null;
|
||||
this._finishing = false;
|
||||
}
|
||||
|
||||
open(text, callback) {
|
||||
this.label.text = text;
|
||||
this._callback = callback;
|
||||
this._finishing = false;
|
||||
|
||||
this._layout();
|
||||
|
||||
UI.add(this);
|
||||
this.menu.open();
|
||||
}
|
||||
|
||||
_finish(result) {
|
||||
// menu.onClosed calling back into _finish (via close() below) would
|
||||
// otherwise recurse into itself.
|
||||
if(this._finishing) return;
|
||||
this._finishing = true;
|
||||
|
||||
this.menu.close();
|
||||
UI.remove(this);
|
||||
|
||||
const callback = this._callback;
|
||||
this._callback = null;
|
||||
if(callback) callback(result);
|
||||
}
|
||||
|
||||
_layout() {
|
||||
this.backdrop.width = Screen.width;
|
||||
this.backdrop.height = Screen.height;
|
||||
|
||||
const panelX = (Screen.width - Confirm.PANEL_WIDTH) / 2;
|
||||
const panelY = (Screen.height - Confirm.PANEL_HEIGHT) / 2;
|
||||
this.panel.x = panelX;
|
||||
this.panel.y = panelY;
|
||||
|
||||
this.label.x = panelX + Confirm.PADDING;
|
||||
this.label.y = panelY + Confirm.PADDING;
|
||||
|
||||
this.confirmButton.x = panelX + Confirm.PADDING;
|
||||
this.confirmButton.y = panelY + Confirm.PANEL_HEIGHT - Confirm.PADDING - 32;
|
||||
|
||||
this.cancelButton.x = this.confirmButton.x + 130;
|
||||
this.cancelButton.y = this.confirmButton.y;
|
||||
}
|
||||
}
|
||||
|
||||
Confirm.PANEL_WIDTH = 300;
|
||||
Confirm.PANEL_HEIGHT = 120;
|
||||
Confirm.PADDING = 16;
|
||||
Confirm.COLOR_BACKDROP = { r: 0, g: 0, b: 0, a: 160 };
|
||||
Confirm.COLOR_PANEL = { r: 40, g: 40, b: 40, a: 255 };
|
||||
|
||||
Confirm.open = function(text, callback) {
|
||||
if(!Confirm._shared) Confirm._shared = new Confirm();
|
||||
Confirm._shared.open(text, callback);
|
||||
return Confirm._shared;
|
||||
};
|
||||
|
||||
module.exports = Confirm;
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
// One Label per console history slot, refreshed only when
|
||||
// Console.consumeDirty() says the history actually changed -- reuses
|
||||
// the normal Label/spritebatch render path instead of the old
|
||||
// hand-rolled mesh-cache renderer.
|
||||
//
|
||||
// These Labels are NOT add()ed as children -- UIElement.add() caps at 8
|
||||
// children per element, and CONSOLE_HISTORY_MAX (Console.lineCount) is
|
||||
// 16, over that cap. Instead they're driven directly: render()/update()
|
||||
// called explicitly below, and their world position is just their own
|
||||
// x/y (never having a parent means nothing offsets it).
|
||||
class ConsoleOverlay extends UIElement {
|
||||
init() {
|
||||
this.lines = [];
|
||||
for(let i = 0; i < Console.lineCount; i++) {
|
||||
const label = new Label();
|
||||
label.x = 4;
|
||||
label.y = 4 + i * ConsoleOverlay.LINE_HEIGHT;
|
||||
this.lines.push(label);
|
||||
}
|
||||
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
update() {
|
||||
this.updateChildren();
|
||||
if(Console.consumeDirty()) this._refresh();
|
||||
for(let i = 0; i < this.lines.length; i++) this.lines[i].update();
|
||||
}
|
||||
|
||||
// Overridden (rather than left to auto-render) so the console can be
|
||||
// hidden entirely without disposing/rebuilding its Labels.
|
||||
render() {
|
||||
this.renderChildren();
|
||||
if(!Console.visible) return;
|
||||
for(let i = 0; i < this.lines.length; i++) this.lines[i].render();
|
||||
}
|
||||
|
||||
// this.lines was never add()ed, so the normal dispose() cascade can't
|
||||
// reach them -- release them explicitly first, or they'd leak their
|
||||
// pool slots.
|
||||
dispose() {
|
||||
for(let i = 0; i < this.lines.length; i++) this.lines[i].dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
_refresh() {
|
||||
for(let i = 0; i < this.lines.length; i++) {
|
||||
this.lines[i].text = Console.getLine(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ConsoleOverlay.LINE_HEIGHT = 12;
|
||||
|
||||
module.exports = ConsoleOverlay;
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
// Up to 4 black letterbox/pillarbox bars sized from Screen.scanX/Y/
|
||||
// scanWidth/scanHeight vs Screen.width/height. Recomputed every tick,
|
||||
// unconditionally -- cheap (four rectangle field writes), and a no-op
|
||||
// when the scan area already covers the full viewport (Rectangle
|
||||
// already skips rendering at width/height <= 0).
|
||||
class Crop extends UIElement {
|
||||
init() {
|
||||
this.top = new Rectangle();
|
||||
this.bottom = new Rectangle();
|
||||
this.left = new Rectangle();
|
||||
this.right = new Rectangle();
|
||||
|
||||
this.top.color = Crop.COLOR_BARS;
|
||||
this.bottom.color = Crop.COLOR_BARS;
|
||||
this.left.color = Crop.COLOR_BARS;
|
||||
this.right.color = Crop.COLOR_BARS;
|
||||
|
||||
this.add(this.top);
|
||||
this.add(this.bottom);
|
||||
this.add(this.left);
|
||||
this.add(this.right);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.updateChildren();
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
_refresh() {
|
||||
const scanX = Screen.scanX;
|
||||
const scanY = Screen.scanY;
|
||||
const scanRight = scanX + Screen.scanWidth;
|
||||
const scanBottom = scanY + Screen.scanHeight;
|
||||
|
||||
this.top.x = 0;
|
||||
this.top.y = 0;
|
||||
this.top.width = Screen.width;
|
||||
this.top.height = scanY;
|
||||
|
||||
this.bottom.x = 0;
|
||||
this.bottom.y = scanBottom;
|
||||
this.bottom.width = Screen.width;
|
||||
this.bottom.height = Screen.height - scanBottom;
|
||||
|
||||
this.left.x = 0;
|
||||
this.left.y = scanY;
|
||||
this.left.width = scanX;
|
||||
this.left.height = Screen.scanHeight;
|
||||
|
||||
this.right.x = scanRight;
|
||||
this.right.y = scanY;
|
||||
this.right.width = Screen.width - scanRight;
|
||||
this.right.height = Screen.scanHeight;
|
||||
}
|
||||
}
|
||||
|
||||
Crop.COLOR_BARS = { r: 0, g: 0, b: 0, a: 255 };
|
||||
|
||||
module.exports = Crop;
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
// Label + a "< option >"-style value label, cycling a caller-provided
|
||||
// options array. Same directionInput()-consumes-LEFT/RIGHT shape as
|
||||
// Slider, stepping the selected index (with wraparound) instead of a
|
||||
// numeric value.
|
||||
class Dropdown extends UIElement {
|
||||
init() {
|
||||
this.label = new Label();
|
||||
this.add(this.label);
|
||||
|
||||
this.valueLabel = new Label();
|
||||
this.valueLabel.x = 100;
|
||||
this.add(this.valueLabel);
|
||||
|
||||
this.options = [];
|
||||
this._index = 0;
|
||||
this.highlighted = false;
|
||||
this.onChange = null;
|
||||
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
get text() { return this.label.text; }
|
||||
set text(value) { this.label.text = value; }
|
||||
|
||||
get selectedIndex() { return this._index; }
|
||||
set selectedIndex(index) {
|
||||
if(this.options.length === 0) {
|
||||
this._index = 0;
|
||||
this._refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const count = this.options.length;
|
||||
this._index = ((index % count) + count) % count;
|
||||
this._refresh();
|
||||
if(this.onChange) this.onChange(this._index, this.options[this._index]);
|
||||
}
|
||||
|
||||
get selected() { return this.options[this._index]; }
|
||||
|
||||
setHighlighted(highlighted) {
|
||||
this.highlighted = highlighted;
|
||||
this.label.color = highlighted
|
||||
? Dropdown.COLOR_HIGHLIGHTED
|
||||
: Dropdown.COLOR_NORMAL;
|
||||
}
|
||||
|
||||
directionInput(dx, dy) {
|
||||
if(dx === 0) return false;
|
||||
this.selectedIndex = this._index + dx;
|
||||
return true;
|
||||
}
|
||||
|
||||
_refresh() {
|
||||
const value = this.options[this._index];
|
||||
this.valueLabel.text = value !== undefined ? ('< ' + value + ' >') : '< >';
|
||||
}
|
||||
}
|
||||
|
||||
Dropdown.COLOR_NORMAL = { r: 255, g: 255, b: 255, a: 255 };
|
||||
Dropdown.COLOR_HIGHLIGHTED = { r: 255, g: 0, b: 0, a: 255 };
|
||||
|
||||
module.exports = Dropdown;
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
// extends UIElement (not Label) deliberately: only a UIElement/SCRIPTED
|
||||
// instance's render()/update() override actually gets dispatched by the
|
||||
// engine (the native Label/Rectangle callback tables never consult a
|
||||
// subclass's override -- they're not scripted types themselves). So this
|
||||
// composes a child Label instead of subclassing one, same as Button.
|
||||
//
|
||||
// Overrides render() rather than update(): update()/uiUpdate() only run
|
||||
// on the one fixed-timestep tick per real frame that actually crosses a
|
||||
// DUSK_TIME_STEP boundary (see Time.renderDelta's doc), but an FPS
|
||||
// counter needs to sample every real frame -- which is exactly what
|
||||
// render() does.
|
||||
class FpsCounter extends UIElement {
|
||||
init() {
|
||||
this.label = new Label();
|
||||
this.add(this.label);
|
||||
|
||||
this._average = 0;
|
||||
}
|
||||
|
||||
render() {
|
||||
const delta = Time.renderDelta;
|
||||
if(delta > 0) {
|
||||
const fps = 1 / delta;
|
||||
this._average = this._average === 0
|
||||
? fps
|
||||
: this._average + (fps - this._average) * FpsCounter.SMOOTHING;
|
||||
}
|
||||
|
||||
this.label.text = 'FPS: ' + Math.round(this._average);
|
||||
this.renderChildren();
|
||||
}
|
||||
}
|
||||
|
||||
// Exponential-moving-average weight per sample -- lower is smoother/
|
||||
// slower to react, higher tracks instantaneous frame time more closely.
|
||||
FpsCounter.SMOOTHING = 0.1;
|
||||
|
||||
module.exports = FpsCounter;
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
// A tweened full-screen color quad -- covers both the old uifullbox
|
||||
// (full-screen fade) and uitransition (fade transition with a
|
||||
// completion callback): same shape, just parameterized, so there's no
|
||||
// separate transition class/global.
|
||||
//
|
||||
// extends UIElement composing a child Rectangle (not extends Rectangle)
|
||||
// for the same reason as FpsCounter: only a UIElement/SCRIPTED
|
||||
// instance's render() override is actually dispatched by the engine.
|
||||
// Overrides render() (not update()) so the tween advances every real
|
||||
// frame, not just on fixed-timestep ticks.
|
||||
//
|
||||
// A finished Fullbox is only removed from the overlay roots, never
|
||||
// disposed -- disposing an element from inside its own render()
|
||||
// callback would free its JS instance registration while a native C
|
||||
// caller further up the same call stack still holds it, risking the
|
||||
// exact kind of delayed JerryScript refcount corruption this project
|
||||
// has already hit once. Fullbox.under()/over() lazily create and cache
|
||||
// one shared, reusable instance each (mirroring the old UNDER/OVER
|
||||
// singletons), so repeated fades cost zero additional pool slots.
|
||||
class Fullbox extends UIElement {
|
||||
init() {
|
||||
this.rect = new Rectangle();
|
||||
this.add(this.rect);
|
||||
|
||||
this._from = Fullbox.COLOR_TRANSPARENT;
|
||||
this._to = Fullbox.COLOR_TRANSPARENT;
|
||||
this._duration = 0;
|
||||
this._t = 0;
|
||||
this._onComplete = null;
|
||||
this._active = false;
|
||||
}
|
||||
|
||||
// Starts (or restarts) this Fullbox tweening rect.color from
|
||||
// options.fromColor to options.toColor over options.duration seconds,
|
||||
// calling options.onComplete() once when done. Adds itself to the
|
||||
// overlay roots if it isn't already there.
|
||||
play(options) {
|
||||
this._from = options.fromColor || Fullbox.COLOR_TRANSPARENT;
|
||||
this._to = options.toColor || Fullbox.COLOR_TRANSPARENT;
|
||||
this._duration = options.duration || 0;
|
||||
this._t = 0;
|
||||
this._onComplete = options.onComplete || null;
|
||||
this._active = true;
|
||||
|
||||
this.rect.width = Screen.width;
|
||||
this.rect.height = Screen.height;
|
||||
this.rect.color = this._from;
|
||||
|
||||
UI.addOverlay(this);
|
||||
}
|
||||
|
||||
render() {
|
||||
if(this._active) {
|
||||
this._t += Time.renderDelta;
|
||||
const ratio = this._duration > 0
|
||||
? Math.min(1, this._t / this._duration)
|
||||
: 1;
|
||||
|
||||
this.rect.color = Fullbox._lerpColor(this._from, this._to, ratio);
|
||||
|
||||
if(ratio >= 1) {
|
||||
this._active = false;
|
||||
UI.removeOverlay(this);
|
||||
if(this._onComplete) this._onComplete();
|
||||
}
|
||||
}
|
||||
|
||||
this.renderChildren();
|
||||
}
|
||||
}
|
||||
|
||||
Fullbox.COLOR_TRANSPARENT = { r: 0, g: 0, b: 0, a: 0 };
|
||||
|
||||
Fullbox._lerpColor = function(from, to, t) {
|
||||
return {
|
||||
r: from.r + (to.r - from.r) * t,
|
||||
g: from.g + (to.g - from.g) * t,
|
||||
b: from.b + (to.b - from.b) * t,
|
||||
a: from.a + (to.a - from.a) * t
|
||||
};
|
||||
};
|
||||
|
||||
Fullbox.under = function() {
|
||||
if(!Fullbox._under) Fullbox._under = new Fullbox();
|
||||
return Fullbox._under;
|
||||
};
|
||||
|
||||
Fullbox.over = function() {
|
||||
if(!Fullbox._over) Fullbox._over = new Fullbox();
|
||||
return Fullbox._over;
|
||||
};
|
||||
|
||||
module.exports = Fullbox;
|
||||
@@ -0,0 +1,187 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
// Gamepad/keyboard focus-navigation, replacing the old global uifocus_t
|
||||
// stack. A Menu's children still render/update through the normal tree
|
||||
// (add()), but navigation uses its own `items` list -- addItem() calls
|
||||
// add() and also tracks the widget as focusable, so decorative elements
|
||||
// (a title Label, a Rectangle background) just use add() directly and
|
||||
// never enter navigation.
|
||||
//
|
||||
// open()/close() push/pop this Menu on a small global stack -- only the
|
||||
// topmost entry consumes CANCEL/ACCEPT/direction input each tick, so a
|
||||
// Menu opened on top of another (e.g. a Settings sub-page menu, or a
|
||||
// Confirm dialog) doesn't fight it for input; everything underneath
|
||||
// keeps rendering/updating, just doesn't react to input until it's
|
||||
// topmost again.
|
||||
class Menu extends UIElement {
|
||||
init() {
|
||||
this.items = [];
|
||||
this.cols = 1;
|
||||
this.index = 0;
|
||||
|
||||
this.onSelected = null;
|
||||
this.onChanged = null;
|
||||
this.onClosed = null;
|
||||
|
||||
this._heldDir = null;
|
||||
this._heldTime = 0;
|
||||
}
|
||||
|
||||
addItem(widget) {
|
||||
this.add(widget);
|
||||
this.items.push(widget);
|
||||
return widget;
|
||||
}
|
||||
|
||||
open() {
|
||||
if(Menu._stack.indexOf(this) !== -1) return;
|
||||
Menu._stack.push(this);
|
||||
|
||||
const item = this.items[this.index];
|
||||
if(item && item.setHighlighted) item.setHighlighted(true);
|
||||
}
|
||||
|
||||
// Closes this Menu and anything opened on top of it, matching the old
|
||||
// uifocus_t's uiFocusPopItem (popping a mid-stack item also pops
|
||||
// everything above it).
|
||||
close() {
|
||||
const idx = Menu._stack.indexOf(this);
|
||||
if(idx === -1) return;
|
||||
|
||||
while(Menu._stack.length > idx) {
|
||||
Menu._stack.pop()._onClosed();
|
||||
}
|
||||
}
|
||||
|
||||
// Safety net: dispose() has no override-preserving trampoline the way
|
||||
// render()/update() do (see UIElement's dispose() docs), so a bare
|
||||
// this.menu.dispose() would leave a disposed instance stuck at the top
|
||||
// of Menu._stack forever if the caller forgot to close() first --
|
||||
// always close() (a no-op if never opened) before the native cascade.
|
||||
dispose() {
|
||||
this.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
_onClosed() {
|
||||
for(let i = 0; i < this.items.length; i++) {
|
||||
const item = this.items[i];
|
||||
if(item.setHighlighted) item.setHighlighted(false);
|
||||
}
|
||||
if(this.onClosed) this.onClosed();
|
||||
}
|
||||
|
||||
update() {
|
||||
this.updateChildren();
|
||||
|
||||
if(Menu.top() !== this) return;
|
||||
|
||||
if(Input.pressed(InputBind.CANCEL)) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if(Input.pressed(InputBind.ACCEPT) && this.onSelected) {
|
||||
this.onSelected(this.index, this.items[this.index]);
|
||||
}
|
||||
|
||||
this._handleDirection();
|
||||
}
|
||||
|
||||
_handleDirection() {
|
||||
const dir = this._currentDirection();
|
||||
|
||||
if(!dir) {
|
||||
this._heldDir = null;
|
||||
this._heldTime = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if(dir !== this._heldDir) {
|
||||
this._heldDir = dir;
|
||||
this._heldTime = 0;
|
||||
this._moveDirection(dir.dx, dir.dy);
|
||||
return;
|
||||
}
|
||||
|
||||
this._heldTime += Time.delta;
|
||||
if(this._heldTime >= Menu.HOLD_DELAY) {
|
||||
this._heldTime -= Menu.HOLD_REPEAT;
|
||||
this._moveDirection(dir.dx, dir.dy);
|
||||
}
|
||||
}
|
||||
|
||||
// Identity-stable direction markers so _handleDirection's `dir !==
|
||||
// this._heldDir` check works without allocating a fresh object (and
|
||||
// therefore never matching) every tick.
|
||||
_currentDirection() {
|
||||
if(Input.isDown(InputBind.LEFT)) return Menu.DIR_LEFT;
|
||||
if(Input.isDown(InputBind.RIGHT)) return Menu.DIR_RIGHT;
|
||||
if(Input.isDown(InputBind.UP)) return Menu.DIR_UP;
|
||||
if(Input.isDown(InputBind.DOWN)) return Menu.DIR_DOWN;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Gives the currently-highlighted item first refusal (a Slider/
|
||||
// Dropdown/Checkbox consuming LEFT/RIGHT itself), falling back to
|
||||
// moving the cursor -- mirrors uifocus_t's per-item `direction`
|
||||
// callback.
|
||||
_moveDirection(dx, dy) {
|
||||
if(this.items.length === 0) return;
|
||||
|
||||
const item = this.items[this.index];
|
||||
if(item && typeof item.directionInput === 'function') {
|
||||
if(item.directionInput(dx, dy)) return;
|
||||
}
|
||||
|
||||
this._moveIndex(dx, dy);
|
||||
}
|
||||
|
||||
_moveIndex(dx, dy) {
|
||||
const count = this.items.length;
|
||||
if(count === 0) return;
|
||||
|
||||
const cols = Math.max(1, this.cols);
|
||||
const rows = Math.ceil(count / cols);
|
||||
|
||||
const col = ((this.index % cols) + dx + cols) % cols;
|
||||
const row = ((Math.floor(this.index / cols)) + dy + rows) % rows;
|
||||
|
||||
let next = row * cols + col;
|
||||
if(next >= count) next = count - 1;
|
||||
|
||||
this._setIndex(next);
|
||||
}
|
||||
|
||||
_setIndex(next) {
|
||||
if(next === this.index) return;
|
||||
|
||||
const oldItem = this.items[this.index];
|
||||
if(oldItem && oldItem.setHighlighted) oldItem.setHighlighted(false);
|
||||
|
||||
this.index = next;
|
||||
|
||||
const newItem = this.items[this.index];
|
||||
if(newItem && newItem.setHighlighted) newItem.setHighlighted(true);
|
||||
|
||||
if(this.onChanged) this.onChanged(this.index, newItem);
|
||||
}
|
||||
}
|
||||
|
||||
Menu._stack = [];
|
||||
Menu.top = function() {
|
||||
return Menu._stack.length > 0 ? Menu._stack[Menu._stack.length - 1] : null;
|
||||
};
|
||||
|
||||
Menu.HOLD_DELAY = 0.5;
|
||||
Menu.HOLD_REPEAT = 0.1;
|
||||
|
||||
Menu.DIR_LEFT = { dx: -1, dy: 0 };
|
||||
Menu.DIR_RIGHT = { dx: 1, dy: 0 };
|
||||
Menu.DIR_UP = { dx: 0, dy: -1 };
|
||||
Menu.DIR_DOWN = { dx: 0, dy: 1 };
|
||||
|
||||
module.exports = Menu;
|
||||
@@ -1,6 +1,9 @@
|
||||
var Player = require('./Player.js');
|
||||
var PlayerCamera = require('./PlayerCamera.js');
|
||||
var TestPlane = require('./TestPlane.js');
|
||||
var TestUIElement = require('./TestUIElement.js');
|
||||
var FpsCounter = require('./FpsCounter.js');
|
||||
var ConsoleOverlay = require('./ConsoleOverlay.js');
|
||||
|
||||
class OverworldScene {
|
||||
constructor() {
|
||||
@@ -11,15 +14,14 @@ class OverworldScene {
|
||||
this.plane = new TestPlane();
|
||||
this.camera = new PlayerCamera(this.player);
|
||||
|
||||
this.rect = new Rectangle();
|
||||
this.rect.color = { r: 255, g: 0, b: 0, a: 255 };
|
||||
this.rect.width = 100;
|
||||
this.rect.height = 100;
|
||||
UI.add(this.rect);
|
||||
|
||||
this.label = new Label();
|
||||
this.label.text = 'Hello World!';
|
||||
this.rect.add(this.label);
|
||||
this.testUi = new TestUIElement();
|
||||
UI.add(this.testUi);
|
||||
|
||||
this.fpsCounter = new FpsCounter();
|
||||
UI.addOverlay(this.fpsCounter);
|
||||
|
||||
this.consoleOverlay = new ConsoleOverlay();
|
||||
UI.addOverlay(this.consoleOverlay);
|
||||
}
|
||||
|
||||
lateUpdate() {
|
||||
@@ -31,16 +33,19 @@ class OverworldScene {
|
||||
this.player.dispose();
|
||||
this.plane.dispose();
|
||||
|
||||
// rect.dispose() cascades to label (added as its child) and removes
|
||||
// itself from UI's render roots -- no separate UI.remove()/dispose()
|
||||
// needed for either.
|
||||
this.rect.dispose();
|
||||
// testUi.dispose() cascades to its background/label children and
|
||||
// removes itself from UI's render roots -- no separate UI.remove()
|
||||
// needed.
|
||||
this.testUi.dispose();
|
||||
this.fpsCounter.dispose();
|
||||
this.consoleOverlay.dispose();
|
||||
|
||||
this.camera = null;
|
||||
this.camera = null;
|
||||
this.player = null;
|
||||
this.plane = null;
|
||||
this.label = null;
|
||||
this.rect = null;
|
||||
this.testUi = null;
|
||||
this.fpsCounter = null;
|
||||
this.consoleOverlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
var Menu = require('./Menu.js');
|
||||
var Tab = require('./Tab.js');
|
||||
var Confirm = require('./Confirm.js');
|
||||
var SettingsGeneral = require('./SettingsGeneral.js');
|
||||
var SettingsInput = require('./SettingsInput.js');
|
||||
|
||||
// Display/Audio pages are deliberately not here -- neither has any real
|
||||
// engine system behind it, even in the old code (no resolution/
|
||||
// fullscreen system, no audio subsystem at all).
|
||||
var TABS = [
|
||||
{ name: 'General', ctor: SettingsGeneral },
|
||||
{ name: 'Input', ctor: SettingsInput }
|
||||
];
|
||||
|
||||
// Owns a tabs Menu (General/Input) and swaps in the active tab's page
|
||||
// widgets into a second, page-scoped Menu -- both Menus are open on the
|
||||
// navigation stack simultaneously (tabsMenu below, pageMenu on top),
|
||||
// matching the old settings screen's nested-menu behavior. Switching
|
||||
// away from a dirty page opens a Confirm first.
|
||||
class Settings extends UIElement {
|
||||
init() {
|
||||
this.tabsMenu = new Menu();
|
||||
this.tabsMenu.cols = TABS.length;
|
||||
this.add(this.tabsMenu);
|
||||
|
||||
this.tabs = TABS.map((def, index) => {
|
||||
const tab = new Tab();
|
||||
tab.text = def.name;
|
||||
tab.x = index * 90;
|
||||
this.tabsMenu.addItem(tab);
|
||||
return tab;
|
||||
});
|
||||
|
||||
this.tabsMenu.onSelected = (index) => this._activateTab(index);
|
||||
|
||||
this.page = null;
|
||||
this.pageMenu = null;
|
||||
this._activeTabIndex = -1;
|
||||
}
|
||||
|
||||
open() {
|
||||
UI.add(this);
|
||||
this.tabsMenu.open();
|
||||
this._activateTab(0);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.tabsMenu.close();
|
||||
if(this.pageMenu) this.pageMenu.close();
|
||||
UI.remove(this);
|
||||
}
|
||||
|
||||
_activateTab(index) {
|
||||
if(index === this._activeTabIndex) return;
|
||||
|
||||
if(this.page && this.page.hasChanges && this.page.hasChanges()) {
|
||||
Confirm.open('Discard changes?', (confirmed) => {
|
||||
if(confirmed) this._swapTab(index);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this._swapTab(index);
|
||||
}
|
||||
|
||||
_swapTab(index) {
|
||||
// pageMenu.dispose() cascades to every widget the outgoing page
|
||||
// handed it via menuItems -- the page itself is a plain object (see
|
||||
// SettingsGeneral.js), nothing else to release.
|
||||
if(this.pageMenu) this.pageMenu.dispose();
|
||||
|
||||
for(let i = 0; i < this.tabs.length; i++) {
|
||||
this.tabs[i].setHighlighted(i === index);
|
||||
}
|
||||
|
||||
const def = TABS[index];
|
||||
this.page = new def.ctor();
|
||||
if(this.page.load) this.page.load();
|
||||
|
||||
this.pageMenu = new Menu();
|
||||
this.pageMenu.y = 40;
|
||||
const items = this.page.menuItems || [];
|
||||
for(let i = 0; i < items.length; i++) this.pageMenu.addItem(items[i]);
|
||||
this.add(this.pageMenu);
|
||||
this.pageMenu.open();
|
||||
|
||||
this._activeTabIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Settings;
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
var Dropdown = require('./Dropdown.js');
|
||||
var Button = require('./Button.js');
|
||||
|
||||
// A plain class, not a UIElement -- it owns no visual tree of its own.
|
||||
// Its widgets are handed to Settings.js's pageMenu (via menuItems),
|
||||
// which owns their parenting/rendering/disposal; this only holds
|
||||
// load()/apply()/hasChanges() logic, mirroring the old settings system's
|
||||
// per-tab def struct.
|
||||
class SettingsGeneral {
|
||||
constructor() {
|
||||
this.dropdown = new Dropdown();
|
||||
this.dropdown.text = 'Language';
|
||||
this.dropdown.options = ['en-US', 'ja-JP', 'es-MX'];
|
||||
|
||||
this.applyButton = new Button();
|
||||
this.applyButton.text = 'Apply';
|
||||
this.applyButton.y = 40;
|
||||
this.applyButton.onSelect = () => this.apply();
|
||||
|
||||
this._loadedIndex = 0;
|
||||
}
|
||||
|
||||
get menuItems() {
|
||||
return [this.dropdown, this.applyButton];
|
||||
}
|
||||
|
||||
load() {
|
||||
const index = this.dropdown.options.indexOf(Locale.current);
|
||||
this.dropdown.selectedIndex = index >= 0 ? index : 0;
|
||||
this._loadedIndex = this.dropdown.selectedIndex;
|
||||
}
|
||||
|
||||
apply() {
|
||||
Locale.set(this.dropdown.options[this.dropdown.selectedIndex]);
|
||||
this._loadedIndex = this.dropdown.selectedIndex;
|
||||
}
|
||||
|
||||
hasChanges() {
|
||||
return this.dropdown.selectedIndex !== this._loadedIndex;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SettingsGeneral;
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
var Slider = require('./Slider.js');
|
||||
var Button = require('./Button.js');
|
||||
|
||||
// A plain class, not a UIElement -- see SettingsGeneral.js for why.
|
||||
class SettingsInput {
|
||||
constructor() {
|
||||
this.deadzoneSlider = new Slider();
|
||||
this.deadzoneSlider.text = 'Deadzone';
|
||||
this.deadzoneSlider.min = 0;
|
||||
this.deadzoneSlider.max = 1;
|
||||
this.deadzoneSlider.step = 0.05;
|
||||
|
||||
this.applyButton = new Button();
|
||||
this.applyButton.text = 'Apply';
|
||||
this.applyButton.y = 40;
|
||||
this.applyButton.onSelect = () => this.apply();
|
||||
|
||||
this._loadedValue = 0;
|
||||
}
|
||||
|
||||
get menuItems() {
|
||||
return [this.deadzoneSlider, this.applyButton];
|
||||
}
|
||||
|
||||
load() {
|
||||
this.deadzoneSlider.value = Input.deadzone;
|
||||
this._loadedValue = this.deadzoneSlider.value;
|
||||
}
|
||||
|
||||
apply() {
|
||||
Input.deadzone = this.deadzoneSlider.value;
|
||||
Save.write();
|
||||
this._loadedValue = this.deadzoneSlider.value;
|
||||
}
|
||||
|
||||
hasChanges() {
|
||||
return this.deadzoneSlider.value !== this._loadedValue;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SettingsInput;
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
// Name label + value label + a track/fill Rectangle pair. Unlike Button,
|
||||
// a Slider doesn't fire on ACCEPT -- it consumes LEFT/RIGHT itself via
|
||||
// directionInput(), stepping value with wraparound at min/max, which is
|
||||
// how a Menu gives the currently-highlighted item first refusal on
|
||||
// direction input before falling back to moving the cursor.
|
||||
class Slider extends UIElement {
|
||||
init() {
|
||||
this.nameLabel = new Label();
|
||||
this.add(this.nameLabel);
|
||||
|
||||
this.valueLabel = new Label();
|
||||
this.valueLabel.x = 100;
|
||||
this.add(this.valueLabel);
|
||||
|
||||
this.track = new Rectangle();
|
||||
this.track.y = 16;
|
||||
this.track.width = 100;
|
||||
this.track.height = 4;
|
||||
this.track.color = Slider.COLOR_TRACK;
|
||||
this.add(this.track);
|
||||
|
||||
this.fill = new Rectangle();
|
||||
this.fill.y = 16;
|
||||
this.fill.height = 4;
|
||||
this.fill.color = Slider.COLOR_FILL;
|
||||
this.add(this.fill);
|
||||
|
||||
this._min = 0;
|
||||
this._max = 1;
|
||||
this.step = 0.1;
|
||||
this._value = 0;
|
||||
this.highlighted = false;
|
||||
this.onChange = null;
|
||||
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
get text() { return this.nameLabel.text; }
|
||||
set text(value) { this.nameLabel.text = value; }
|
||||
|
||||
get min() { return this._min; }
|
||||
set min(value) { this._min = value; this._refresh(); }
|
||||
|
||||
get max() { return this._max; }
|
||||
set max(value) { this._max = value; this._refresh(); }
|
||||
|
||||
get value() { return this._value; }
|
||||
set value(v) {
|
||||
this._value = Math.min(this._max, Math.max(this._min, v));
|
||||
this._refresh();
|
||||
if(this.onChange) this.onChange(this._value);
|
||||
}
|
||||
|
||||
setHighlighted(highlighted) {
|
||||
this.highlighted = highlighted;
|
||||
this.nameLabel.color = highlighted
|
||||
? Slider.COLOR_HIGHLIGHTED
|
||||
: Slider.COLOR_NORMAL;
|
||||
}
|
||||
|
||||
directionInput(dx, dy) {
|
||||
if(dx === 0) return false;
|
||||
|
||||
let next = this._value + dx * this.step;
|
||||
const range = this._max - this._min;
|
||||
if(range > 0) {
|
||||
// Wrap within [min, max) rather than clamp, matching the old
|
||||
// slider's step-with-wraparound behavior.
|
||||
next = this._min + (((next - this._min) % range) + range) % range;
|
||||
}
|
||||
this.value = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
_refresh() {
|
||||
this.valueLabel.text = String(Math.round(this._value * 100) / 100);
|
||||
const ratio = this._max > this._min
|
||||
? (this._value - this._min) / (this._max - this._min)
|
||||
: 0;
|
||||
this.fill.width = this.track.width * ratio;
|
||||
}
|
||||
}
|
||||
|
||||
Slider.COLOR_NORMAL = { r: 255, g: 255, b: 255, a: 255 };
|
||||
Slider.COLOR_HIGHLIGHTED = { r: 255, g: 0, b: 0, a: 255 };
|
||||
Slider.COLOR_TRACK = { r: 80, g: 80, b: 80, a: 255 };
|
||||
Slider.COLOR_FILL = { r: 0, g: 160, b: 220, a: 255 };
|
||||
|
||||
module.exports = Slider;
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
// A background Rectangle + Label, structurally a re-skinned Button --
|
||||
// "active" (which content tab is currently shown) plays the same role
|
||||
// Button's "highlighted" plays, so setHighlighted() is how a Menu drives
|
||||
// it: a tab strip's Menu treats "cursor is here" and "this tab's content
|
||||
// is showing" as the same thing, matching the old widget's behavior.
|
||||
class Tab extends UIElement {
|
||||
init() {
|
||||
this.background = new Rectangle();
|
||||
this.background.width = 80;
|
||||
this.background.height = 24;
|
||||
this.add(this.background);
|
||||
|
||||
this.label = new Label();
|
||||
this.label.x = 4;
|
||||
this.label.y = 4;
|
||||
this.add(this.label);
|
||||
|
||||
this.active = false;
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
get text() { return this.label.text; }
|
||||
set text(value) { this.label.text = value; }
|
||||
|
||||
setHighlighted(active) {
|
||||
this.active = active;
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
_refresh() {
|
||||
this.background.color = this.active
|
||||
? Tab.COLOR_ACTIVE
|
||||
: Tab.COLOR_INACTIVE;
|
||||
}
|
||||
}
|
||||
|
||||
Tab.COLOR_ACTIVE = { r: 0, g: 160, b: 0, a: 255 };
|
||||
Tab.COLOR_INACTIVE = { r: 160, g: 0, b: 0, a: 255 };
|
||||
|
||||
module.exports = Tab;
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
var Button = require('./Button.js');
|
||||
var Menu = require('./Menu.js');
|
||||
var Settings = require('./Settings.js');
|
||||
|
||||
// No render() override -- UIElement's base render() auto-renders whatever
|
||||
// was added() here, in add-order: background first, labels, then the
|
||||
// menu (and its two buttons) on top of it.
|
||||
class TestUIElement extends UIElement {
|
||||
init() {
|
||||
this.background = new Rectangle();
|
||||
this.background.width = 220;
|
||||
this.background.height = 128;
|
||||
this.background.color = { r: 255, g: 0, b: 0, a: 255 };
|
||||
this.add(this.background);
|
||||
|
||||
this.label = new Label();
|
||||
this.label.text = 'Hello World!';
|
||||
this.label.x = 8;
|
||||
this.label.y = 8;
|
||||
this.add(this.label);
|
||||
|
||||
this.label2 = new Label();
|
||||
this.label2.text = 'Second label!';
|
||||
this.label2.x = 8;
|
||||
this.label2.y = 24;
|
||||
this.add(this.label2);
|
||||
|
||||
// A navigable 2-item Menu -- UP/DOWN moves the highlighted cursor
|
||||
// between the buttons, ACCEPT fires whichever one is highlighted.
|
||||
this.menu = new Menu();
|
||||
this.add(this.menu);
|
||||
|
||||
this.helloButton = new Button();
|
||||
this.helloButton.text = 'Say hello';
|
||||
this.helloButton.x = 8;
|
||||
this.helloButton.y = 48;
|
||||
this.helloButton.onSelect = () => Console.print('Hello!');
|
||||
this.menu.addItem(this.helloButton);
|
||||
|
||||
this.settingsButton = new Button();
|
||||
this.settingsButton.text = 'Open settings';
|
||||
this.settingsButton.x = 8;
|
||||
this.settingsButton.y = 84;
|
||||
this.settingsButton.onSelect = () => new Settings().open();
|
||||
this.menu.addItem(this.settingsButton);
|
||||
|
||||
this.menu.open();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TestUIElement;
|
||||
Reference in New Issue
Block a user