4 Commits

Author SHA1 Message Date
YourWishes b639bc6c4f 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>
2026-08-14 09:05:11 -05:00
YourWishes 7ee04c78cd Rebuild UI as a scriptable element tree, archive the old system
The old UI system (X-macro static element list, hand-authored C
screens/widgets/focus stack) is archived under archive/ rather than
deleted, since it's a useful reference during the rewrite.

New native UI element pool (src/dusk/ui): a flat UI_ELEMENTS[128] pool
of tagged-union elements (Label, Rectangle, Scripted), a real
persistent parent/child tree (children[8] per element, cycle- and
capacity-checked uiElementSetParent), always-fresh worldX/worldY
(cheap enough to recompute on every read, no dirty-flag cache needed),
and cascading dispose. Rendering stays manual/immediate: a scripted
element with no render() override auto-renders its children by
default, but overriding render() takes full control (an override must
call renderChildren() itself to opt back in) -- this is deliberately
preserved end to end via a render()-shadow trampoline so overriding
render() always keeps working the same way regardless of how a node
is reached.

New scripting layer (src/dusk/script/module/ui): UIElement/Label/
Rectangle JS classes (Label/Rectangle share UIElement's prototype via
manual chaining, not JS `extends`), exposing x/y/worldX/worldY/parent/
add()/remove()/render()/renderChildren()/dispose(). UI.add()/
UI.remove() manage top-level render roots, mutually exclusive with
being someone's child.

Also: Scene gains a lateUpdate() hook (called once per frame after
every other update, for things like camera-follow that need to react
to where everything else ended up); several duskrpg call sites
(cutscene items, entityinteractable, entityplayer) that depended on
the now-archived RPG textbox are stubbed to console output instead of
a dialogue box, pending the new UI reaching that far.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:13:23 -05:00
YourWishes 7b58addf7e Prepping UI overhaul 2026-08-11 23:33:29 -05:00
YourWishes ae52be591b Add Input/InputBind/InputButton JS bindings, move default binds to JS
Adds a new script module exposing Input.bind/value/lastValue/isDown/
wasDown/pressed/released, InputBind.* (the inputbind_t action enum),
and InputButton.* (every named button in the compiled platform's
INPUT_BUTTON_DATA table, e.g. InputButton.SELECT on PSP).

Renames the PLATFORM global to Platform.current for consistency with
Time/Console/Input.

Uppercases every INPUT_BUTTON_DATA .name across inputlinux.c/
inputpsp.c/inputdolphin.c to match the InputButton.* JS constants
(inputButtonGetByName was already case-insensitive, so this doesn't
change matching behavior).

Moves the default key/button-to-action bindings out of
src/duskrpg/input/inputbindmap.h (deleted) and into
assets/scripts/input.js, branching on Platform.current instead of the
old per-platform #ifdefs. Wired in via require('./input.js') from
init.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 16:03:32 -05:00
169 changed files with 5775 additions and 644 deletions
+18
View File
@@ -0,0 +1,18 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
add_subdirectory(debug)
add_subdirectory(frame)
add_subdirectory(focus)
add_subdirectory(overlay)
add_subdirectory(transition)
add_subdirectory(widget)
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
ui.c
uielement.c
)
+62
View File
@@ -0,0 +1,62 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "ui.h"
#include "util/memory.h"
#include "assert/assert.h"
#include "display/spritebatch/spritebatch.h"
#include "display/screen/screen.h"
#include "ui/uielement.h"
#include "ui/focus/uifocus.h"
ui_t UI;
errorret_t uiInit(void) {
memoryZero(&UI, sizeof(ui_t));
uiFocusInit();
uiElementsSort();
uielement_t *element = &UI_ELEMENTS[0];
while(!uiElementIsNull(element)) {
errorChain(uiElementInit(element));
element++;
}
errorOk();
}
errorret_t uiUpdate(void) {
uiFocusUpdate();
uielement_t *element = &UI_ELEMENTS[0];
while(!uiElementIsNull(element)) {
errorChain(uiElementUpdate(element));
element++;
}
errorOk();
}
errorret_t uiRender(void) {
const uielement_t *element = &UI_ELEMENTS[0];
while(!uiElementIsNull(element)) {
errorChain(uiElementDraw(element));
element++;
}
errorChain(spriteBatchFlush());
errorOk();
}
errorret_t uiDispose(void) {
uielement_t *element = &UI_ELEMENTS[0];
while(!uiElementIsNull(element)) {
errorChain(uiElementDispose(element));
element++;
}
errorOk();
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
typedef struct {
void *nothing;
} ui_t;
extern ui_t UI;
/**
* Initializes the UI system.
*/
errorret_t uiInit(void);
/**
* Updates the UI system.
*
* @return Any error that occurs.
*/
errorret_t uiUpdate(void);
/**
* Renders the UI system.
*
* @return Any error that occurs.
*/
errorret_t uiRender(void);
/**
* Disposes of the UI system.
*
* @return Any error that occurs.
*/
errorret_t uiDispose(void);
+86
View File
@@ -0,0 +1,86 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uielement.h"
#include "assert/assert.h"
#include "util/sort.h"
#include "ui/frame/uiframe.h"
#include "ui/debug/uifps.h"
#include "engine/engine.h"
#include "ui/overlay/uifullbox.h"
#include "ui/overlay/uiloading.h"
#include "ui/overlay/uicrop.h"
#include "ui/transition/uitransition.h"
#include "ui/debug/uiconsole.h"
#include "ui/frame/uiconfirm.h"
// Priming pass: X does nothing here, so this only exists to process any
// #include directives nested in uielementlist.h/ui/uielementgame.h at
// file scope (each such header's own #pragma once makes it a no-op on
// the real pass below, which happens inside UI_ELEMENTS[]'s braces,
// where a raw #include of a declaration would be invalid).
#define X(initFn, updateFn, drawFn, disposeFn, order) // do nothing
#include "uielementlist.h"
#undef X
uielement_t UI_ELEMENTS[] = {
#define X(initFn, updateFn, drawFn, disposeFn, elementOrder) \
{ \
.init = initFn, .update = updateFn, .draw = drawFn, \
.dispose = disposeFn, .order = elementOrder \
},
#include "uielementlist.h"
#undef X
{ 0 } // Null terminator
};
bool_t uiElementIsNull(const uielement_t *element) {
return element->init == NULL &&
element->update == NULL &&
element->draw == NULL &&
element->dispose == NULL;
}
int_t uiElementCompareOrder(const void *a, const void *b) {
const uielement_t *elementA = (const uielement_t *)a;
const uielement_t *elementB = (const uielement_t *)b;
if(elementA->order < elementB->order) return -1;
if(elementA->order > elementB->order) return 1;
return 0;
}
void uiElementsSort(void) {
// The trailing null terminator is always the last static entry -- sort
// everything before it, and leave it in place.
const size_t count = (sizeof(UI_ELEMENTS) / sizeof(UI_ELEMENTS[0])) - 1;
sortBubble(UI_ELEMENTS, count, sizeof(uielement_t), uiElementCompareOrder);
}
errorret_t uiElementInit(uielement_t *element) {
assertNotNull(element, "element must not be NULL");
if(element->init != NULL) errorChain(element->init());
errorOk();
}
errorret_t uiElementUpdate(uielement_t *element) {
assertNotNull(element, "element must not be NULL");
if(element->update != NULL) errorChain(element->update());
errorOk();
}
errorret_t uiElementDraw(const uielement_t *element) {
assertNotNull(element, "element must not be NULL");
if(element->draw != NULL) errorChain(element->draw());
errorOk();
}
errorret_t uiElementDispose(uielement_t *element) {
assertNotNull(element, "element must not be NULL");
if(element->dispose != NULL) errorChain(element->dispose());
errorOk();
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
// Built-in order tiers. Lower values update/render first; ties preserve
// their relative UI_ELEMENTS declaration order (uiElementsSort uses a
// stable sort). Game-specific elements can use any int32_t value -- these
// are just the ones the engine itself relies on.
#define UI_ELEMENT_ORDER_DEFAULT 0
#define UI_ELEMENT_ORDER_DEBUG 1000
typedef struct {
errorret_t (*init)();
errorret_t (*update)();
errorret_t (*draw)();
errorret_t (*dispose)();
int32_t order;
} uielement_t;
extern uielement_t UI_ELEMENTS[];
/**
* Returns true when all four callbacks on the element are NULL,
* which marks the end of the UI_ELEMENTS array.
*
* @param element The element to test.
* @returns True if the element is the null terminator.
*/
bool_t uiElementIsNull(const uielement_t *element);
/**
* Compares two elements by their .order field, ascending. Matches
* sortcompare_t, for use with the project's sort utilities.
*
* @param a First uielement_t to compare.
* @param b Second uielement_t to compare.
* @return Negative if a < b, zero if a == b, positive if a > b.
*/
int_t uiElementCompareOrder(const void *a, const void *b);
/**
* Stably sorts UI_ELEMENTS in place by .order, ascending. The trailing
* null terminator is never moved. Called once by uiInit -- element order
* is static after that, so there's no need to re-sort every frame.
*/
void uiElementsSort(void);
/**
* Initializes a UI element, invoking its init callback if set.
*
* @param element The element to initialize.
* @return Any error that occurs.
*/
errorret_t uiElementInit(uielement_t *element);
/**
* Updates a UI element, calling its update callback if set.
*
* @param element The element to update.
* @return Any error that occurs.
*/
errorret_t uiElementUpdate(uielement_t *element);
/**
* Draws a UI element, calling its draw callback if set.
*
* @param element The element to render.
* @return Any error that occurs.
*/
errorret_t uiElementDraw(const uielement_t *element);
/**
* Disposes of a UI element, invoking its dispose callback if set.
*
* @param element The element to dispose.
* @return Any error that occurs.
*/
errorret_t uiElementDispose(uielement_t *element);
+17
View File
@@ -0,0 +1,17 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
uibutton.c
uicheckbox.c
uitab.c
uislider.c
uidropdown.c
uiscrolling.c
uimenu.c
uilabel.c
uiwidgetlabel.c
)
+70
View File
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "uilabel.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#include "display/text/text.h"
void uiLabelInit(uilabel_t *label, font_t *font) {
assertNotNull(label, "Label cannot be NULL");
assertNotNull(font, "Font cannot be NULL");
memoryZero(label, sizeof(uilabel_t));
label->font = font;
label->color = COLOR_WHITE;
}
void uiLabelSetText(uilabel_t *label, const char_t *text) {
assertNotNull(label, "Label cannot be NULL");
assertNotNull(text, "Text cannot be NULL");
assertStrLenMax(text, UI_LABEL_TEXT_MAX, "Label text too long");
stringCopy(label->text, text, UI_LABEL_TEXT_MAX);
label->spriteCount = textBuildSpriteCache(
label->text, label->font, label->sprites, UI_LABEL_SPRITE_COUNT_MAX,
&label->width, &label->height
);
}
void uiLabelSetColor(uilabel_t *label, const color_t color) {
assertNotNull(label, "Label cannot be NULL");
label->color = color;
}
void uiLabelGetSize(
const uilabel_t *label,
int32_t *outWidth,
int32_t *outHeight
) {
assertNotNull(label, "Label cannot be NULL");
assertNotNull(outWidth, "Output width cannot be NULL");
assertNotNull(outHeight, "Output height cannot be NULL");
*outWidth = label->width;
*outHeight = label->height;
}
errorret_t uiLabelDraw(
const uilabel_t *label,
const float_t x,
const float_t y
) {
assertNotNull(label, "Label cannot be NULL");
if(label->spriteCount == 0) errorOk();
// Cached sprites are relative to (0,0); textDrawSpriteCache translates
// into the requested screen position here instead of in
// uiLabelSetText, so a label can be repositioned every frame without
// rebuilding the (much more expensive) glyph/UV cache.
spritebatchsprite_t scratch[UI_LABEL_SPRITE_COUNT_MAX];
errorChain(textDrawSpriteCache(
label->sprites, label->spriteCount, scratch, x, y, label->color,
label->font->texture
));
errorOk();
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "display/text/font.h"
#include "display/spritebatch/spritebatchsprite.h"
#include "display/color.h"
#define UI_LABEL_TEXT_MAX 256
#define UI_LABEL_SPRITE_COUNT_MAX UI_LABEL_TEXT_MAX
typedef struct {
char_t text[UI_LABEL_TEXT_MAX];
color_t color;
font_t *font;
spritebatchsprite_t sprites[UI_LABEL_SPRITE_COUNT_MAX];
uint32_t spriteCount;
int32_t width;
int32_t height;
} uilabel_t;
/**
* Initializes a label, defaulting to white text and no text set.
*
* @param label The label to initialize.
* @param font Font to use for rendering. Must outlive the label.
*/
void uiLabelInit(uilabel_t *label, font_t *font);
/**
* Sets the label's text, rebuilding its cached sprites (glyph lookup, UV,
* and layout) immediately. This is the only way the sprite cache gets
* rebuilt -- uiLabelDraw never recomputes it, so call this whenever the
* text changes rather than once up front and expecting it to stay in sync.
*
* @param label The label to update.
* @param text Null-terminated string to display. Must be shorter than
* UI_LABEL_TEXT_MAX.
*/
void uiLabelSetText(uilabel_t *label, const char_t *text);
/**
* Sets the label's tint color. Cheap -- doesn't touch the sprite cache,
* since color is applied via the draw material, not baked into sprites.
*
* @param label The label to update.
* @param color The new tint color.
*/
void uiLabelSetColor(uilabel_t *label, const color_t color);
/**
* Gets the measured size (in pixels) of the label's current text, cached
* from the last uiLabelSetText call.
*
* @param label The label to query.
* @param outWidth Pointer to store the width.
* @param outHeight Pointer to store the height.
*/
void uiLabelGetSize(
const uilabel_t *label,
int32_t *outWidth,
int32_t *outHeight
);
/**
* Draws the label's cached sprites at the given screen position in a
* single batched buffer call. Does not recompute glyph layout -- call
* uiLabelSetText first whenever the text changes.
*
* @param label The label to draw.
* @param x Screen x position.
* @param y Screen y position.
* @return Any error that occurs.
*/
errorret_t uiLabelDraw(
const uilabel_t *label,
const float_t x,
const float_t y
);
+49
View File
@@ -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;
+61
View File
@@ -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;
+110
View File
@@ -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;
+60
View File
@@ -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;
+64
View File
@@ -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;
+68
View File
@@ -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;
+43
View File
@@ -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;
+98
View File
@@ -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;
+187
View File
@@ -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;
+52
View File
@@ -0,0 +1,52 @@
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() {
}
init() {
this.player = new Player();
this.plane = new TestPlane();
this.camera = new PlayerCamera(this.player);
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() {
this.camera.update();
}
dispose() {
this.camera.dispose();
this.player.dispose();
this.plane.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.player = null;
this.plane = null;
this.testUi = null;
this.fpsCounter = null;
this.consoleOverlay = null;
}
}
module.exports = OverworldScene;
+96
View File
@@ -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;
+48
View File
@@ -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;
+46
View File
@@ -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;
+94
View File
@@ -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;
+45
View File
@@ -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;
+56
View File
@@ -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;
+3 -1
View File
@@ -1,3 +1,5 @@
var OverworldScene = require('./overworldscene.js'); require('./input.js');
var OverworldScene = require('./OverworldScene.js');
Scene.set(new OverworldScene()); Scene.set(new OverworldScene());
+88
View File
@@ -0,0 +1,88 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// Default input bindings, ported from the old
// src/duskrpg/input/inputbindmap.h (removed) -- one INPUT_BIND_MAP per
// compile-time target became one branch here on Platform.current.
function bindAll(pairs) {
for(var i = 0; i < pairs.length; i++) {
Input.bind(pairs[i][0], pairs[i][1]);
}
}
if(Platform.current === 'linux' || Platform.current === 'knulli') {
bindAll([
// Keyboard
[InputButton.W, InputBind.UP],
[InputButton.S, InputBind.DOWN],
[InputButton.A, InputBind.LEFT],
[InputButton.D, InputBind.RIGHT],
[InputButton.LEFT, InputBind.LEFT],
[InputButton.RIGHT, InputBind.RIGHT],
[InputButton.UP, InputBind.UP],
[InputButton.DOWN, InputBind.DOWN],
[InputButton.ENTER, InputBind.ACCEPT],
[InputButton.E, InputBind.ACCEPT],
[InputButton.SPACE, InputBind.ACCEPT],
[InputButton.TAB, InputBind.CANCEL],
[InputButton.Q, InputBind.CANCEL],
[InputButton.ESCAPE, InputBind.RAGEQUIT],
[InputButton.ENTER, InputBind.PAUSE],
['`', InputBind.CONSOLE],
// Gamepad
[InputButton.GAMEPAD_UP, InputBind.UP],
[InputButton.GAMEPAD_DOWN, InputBind.DOWN],
[InputButton.GAMEPAD_LEFT, InputBind.LEFT],
[InputButton.GAMEPAD_RIGHT, InputBind.RIGHT],
[InputButton.GAMEPAD_A, InputBind.ACCEPT],
[InputButton.GAMEPAD_B, InputBind.CANCEL],
[InputButton.GAMEPAD_BACK, InputBind.RAGEQUIT],
[InputButton.GAMEPAD_START, InputBind.PAUSE],
[InputButton.GAMEPAD_LSTICK_UP, InputBind.UP],
[InputButton.GAMEPAD_LSTICK_DOWN, InputBind.DOWN],
[InputButton.GAMEPAD_LSTICK_LEFT, InputBind.LEFT],
[InputButton.GAMEPAD_LSTICK_RIGHT, InputBind.RIGHT],
// Mouse
[InputButton.MOUSE_X, InputBind.POINTERX],
[InputButton.MOUSE_Y, InputBind.POINTERY],
]);
} else if(Platform.current === 'psp') {
bindAll([
[InputButton.UP, InputBind.UP],
[InputButton.DOWN, InputBind.DOWN],
[InputButton.LEFT, InputBind.LEFT],
[InputButton.RIGHT, InputBind.RIGHT],
[InputButton.ACCEPT, InputBind.ACCEPT],
[InputButton.CANCEL, InputBind.CANCEL],
[InputButton.TRIANGLE, InputBind.CONSOLE],
[InputButton.SELECT, InputBind.RAGEQUIT],
[InputButton.START, InputBind.PAUSE],
[InputButton.LSTICK_UP, InputBind.UP],
[InputButton.LSTICK_DOWN, InputBind.DOWN],
[InputButton.LSTICK_LEFT, InputBind.LEFT],
[InputButton.LSTICK_RIGHT, InputBind.RIGHT],
]);
} else if(Platform.current === 'gamecube' || Platform.current === 'wii') {
// GameCube and Wii share the same pad layout for now.
// TODO: Wiimote, USB Keyboard, probably more.
bindAll([
[InputButton.UP, InputBind.UP],
[InputButton.DOWN, InputBind.DOWN],
[InputButton.LEFT, InputBind.LEFT],
[InputButton.RIGHT, InputBind.RIGHT],
[InputButton.LSTICK_UP, InputBind.UP],
[InputButton.LSTICK_DOWN, InputBind.DOWN],
[InputButton.LSTICK_LEFT, InputBind.LEFT],
[InputButton.LSTICK_RIGHT, InputBind.RIGHT],
[InputButton.A, InputBind.ACCEPT],
[InputButton.B, InputBind.CANCEL],
[InputButton.Z, InputBind.CONSOLE],
[InputButton.START, InputBind.RAGEQUIT],
[InputButton.START, InputBind.PAUSE],
]);
}
-36
View File
@@ -1,36 +0,0 @@
var Player = require('./Player.js');
var PlayerCamera = require('./PlayerCamera.js');
var TestPlane = require('./TestPlane.js');
class OverworldScene {
constructor() {
this.time = 0;
}
init() {
this.player = new Player();
this.plane = new TestPlane();
this.camera = new PlayerCamera(this.player);
}
update() {
this.camera.update();
this.time += Time.delta;
if(this.time > 3.0) {
Scene.set(new OverworldScene());
}
}
dispose() {
this.camera.dispose();
this.player.dispose();
this.plane.dispose();
this.camera = null;
this.player = null;
this.plane = null;
}
}
module.exports = OverworldScene;
+1
View File
@@ -80,6 +80,7 @@ errorret_t displayUpdate(void) {
); );
errorChain(sceneRender()); errorChain(sceneRender());
errorChain(uiRender());
// Finish up // Finish up
screenUnbind(); screenUnbind();
+1
View File
@@ -90,6 +90,7 @@ errorret_t engineUpdate(void) {
errorChain(sceneUpdate()); errorChain(sceneUpdate());
errorChain(assetUpdate()); errorChain(assetUpdate());
errorChain(uiUpdate()); errorChain(uiUpdate());
errorChain(moduleSceneLateUpdateCurrent());
} }
// Render // Render
-36
View File
@@ -14,7 +14,6 @@
#include "entity/component/display/entityposition.h" #include "entity/component/display/entityposition.h"
#include "entity/component/display/entityrenderable.h" #include "entity/component/display/entityrenderable.h"
#include "physics/triggersystem.h" #include "physics/triggersystem.h"
#include "ui/ui.h"
#include "console/console.h" #include "console/console.h"
scenemanager_t SCENE_MANAGER; scenemanager_t SCENE_MANAGER;
@@ -148,41 +147,6 @@ errorret_t sceneRender(void) {
} }
} }
// Screen-space matrices for UI rendering.
mat4 screenIdentity;
mat4 screenProj;
mat4 screenView;
glm_mat4_identity(screenIdentity);
glm_ortho(
0.0f, (float_t)(SCREEN.width / SCREEN.scaleUi),
(float_t)(SCREEN.height / SCREEN.scaleUi), 0.0f,
0.1f, 100.0f,
screenProj
);
glm_lookat(
(vec3){ 0.0f, 0.0f, 1.0f },
(vec3){ 0.0f, 0.0f, 0.0f },
(vec3){ 0.0f, 1.0f, 0.0f },
screenView
);
errorChain(shaderBind(&SHADER_UNLIT));
errorChain(shaderSetMatrix(
&SHADER_UNLIT, SHADER_UNLIT_MODEL, screenIdentity
));
errorChain(shaderSetMatrix(
&SHADER_UNLIT, SHADER_UNLIT_PROJECTION, screenProj
));
errorChain(shaderSetMatrix(&SHADER_UNLIT, SHADER_UNLIT_VIEW, screenView));
errorChain(displaySetState((displaystate_t){
.flags = DISPLAY_STATE_FLAG_BLEND
}));
errorChain(uiRender());
errorOk(); errorOk();
} }
+5
View File
@@ -14,3 +14,8 @@ add_subdirectory(require)
add_subdirectory(display) add_subdirectory(display)
add_subdirectory(time) add_subdirectory(time)
add_subdirectory(console) add_subdirectory(console)
add_subdirectory(input)
add_subdirectory(screen)
add_subdirectory(locale)
add_subdirectory(save)
add_subdirectory(ui)
@@ -35,9 +35,46 @@ moduleBaseFunction(moduleConsolePrint) {
return jerry_undefined(); return jerry_undefined();
} }
moduleBaseFunction(moduleConsoleGetLineCount) {
return jerry_number(CONSOLE_HISTORY_MAX);
}
moduleBaseFunction(moduleConsoleGetLine) {
moduleBaseRequireArgs(1); moduleBaseRequireNumber(0);
int32_t index = moduleBaseArgInt(0);
if(index < 0 || index >= CONSOLE_HISTORY_MAX) {
return moduleBaseThrow("Console.getLine: index out of range");
}
return jerry_string_sz(CONSOLE.line[index]);
}
moduleBaseFunction(moduleConsoleGetVisible) {
return jerry_boolean(CONSOLE.visible);
}
// Returns the current dirty flag and clears it -- mirrors the
// check-then-clear contract CONSOLE.dirty already documents for native
// consumers, so a script-side console renderer only rebuilds when
// something actually changed since the last time it asked.
moduleBaseFunction(moduleConsoleConsumeDirty) {
bool_t dirty = CONSOLE.dirty;
CONSOLE.dirty = false;
return jerry_boolean(dirty);
}
void moduleConsoleInit(void) { void moduleConsoleInit(void) {
jerry_value_t consoleObj = jerry_object(); jerry_value_t consoleObj = jerry_object();
moduleBaseDefineMethod(consoleObj, "print", moduleConsolePrint); moduleBaseDefineMethod(consoleObj, "print", moduleConsolePrint);
moduleBaseDefineProperty(
consoleObj, "lineCount", moduleConsoleGetLineCount, NULL
);
moduleBaseDefineMethod(consoleObj, "getLine", moduleConsoleGetLine);
moduleBaseDefineProperty(
consoleObj, "visible", moduleConsoleGetVisible, NULL
);
moduleBaseDefineMethod(consoleObj, "consumeDirty", moduleConsoleConsumeDirty);
moduleBaseSetValue("Console", consoleObj); moduleBaseSetValue("Console", consoleObj);
jerry_value_free(consoleObj); jerry_value_free(consoleObj);
} }
@@ -11,7 +11,11 @@
* Registers the global `Console` object, exposing `Console.print(...)`, * Registers the global `Console` object, exposing `Console.print(...)`,
* which stringifies and space-joins its arguments (like console.log) and * which stringifies and space-joins its arguments (like console.log) and
* forwards the result to the engine's consolePrint() (see * forwards the result to the engine's consolePrint() (see
* console/console.h). * console/console.h). Also exposes read access to the console's fixed
* CONSOLE_HISTORY_MAX-line ring buffer for a script-side renderer:
* `Console.lineCount` (always CONSOLE_HISTORY_MAX), `Console.getLine(i)`,
* `Console.visible`, and `Console.consumeDirty()` (returns whether the
* history changed since the last call, clearing the flag).
*/ */
void moduleConsoleInit(void); void moduleConsoleInit(void);

Some files were not shown because too many files have changed in this diff Show More