Compare commits
6 Commits
830864aa8a
...
ac2
| Author | SHA1 | Date | |
|---|---|---|---|
| b639bc6c4f | |||
| 7ee04c78cd | |||
| 7b58addf7e | |||
| ae52be591b | |||
| 93ab7690ba | |||
| 68c3f88181 |
+1
-1
@@ -13,7 +13,7 @@ cmake_policy(SET CMP0079 NEW)
|
|||||||
# set(FETCHCONTENT_UPDATES_DISCONNECTED ON)
|
# set(FETCHCONTENT_UPDATES_DISCONNECTED ON)
|
||||||
|
|
||||||
option(DUSK_BUILD_TESTS "Enable tests" OFF)
|
option(DUSK_BUILD_TESTS "Enable tests" OFF)
|
||||||
option(DUSK_NETWORKING "Enable networking support" ON)
|
option(DUSK_NETWORKING "Enable networking support" OFF)
|
||||||
|
|
||||||
set(DUSK_GAME_NAME "Dusk" CACHE STRING "Game display name")
|
set(DUSK_GAME_NAME "Dusk" CACHE STRING "Game display name")
|
||||||
set(DUSK_GAME_AUTHOR "YourWishes" CACHE STRING "Game author / coder")
|
set(DUSK_GAME_AUTHOR "YourWishes" CACHE STRING "Game author / coder")
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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());
|
||||||
@@ -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],
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -128,8 +128,8 @@ void assetBatchDispose(assetbatch_t *batch) {
|
|||||||
for(uint16_t i = 0; i < batch->count; i++) {
|
for(uint16_t i = 0; i < batch->count; i++) {
|
||||||
if(batch->entries[i]) {
|
if(batch->entries[i]) {
|
||||||
// Unsubscribe while we still hold a lock so the entry is live.
|
// Unsubscribe while we still hold a lock so the entry is live.
|
||||||
eventUnsubscribe(&batch->entries[i]->onLoaded, assetBatchEntryOnLoadedCb);
|
eventUnsubscribe(&batch->entries[i]->onLoaded, assetBatchEntryOnLoadedCb, batch);
|
||||||
eventUnsubscribe(&batch->entries[i]->onError, assetBatchEntryOnErrorCb);
|
eventUnsubscribe(&batch->entries[i]->onError, assetBatchEntryOnErrorCb, batch);
|
||||||
assetUnlockEntry(batch->entries[i]);
|
assetUnlockEntry(batch->entries[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ errorret_t assetFileRead(
|
|||||||
uint8_t tempBuffer[256];
|
uint8_t tempBuffer[256];
|
||||||
while(bytesRemaining > 0) {
|
while(bytesRemaining > 0) {
|
||||||
size_t chunkSize = mathMin(bytesRemaining, sizeof(tempBuffer));
|
size_t chunkSize = mathMin(bytesRemaining, sizeof(tempBuffer));
|
||||||
|
// The recursive call below already advances file->position (real
|
||||||
|
// read branch does that itself) -- don't double-count it here.
|
||||||
errorChain(assetFileRead(file, tempBuffer, chunkSize));
|
errorChain(assetFileRead(file, tempBuffer, chunkSize));
|
||||||
file->position += chunkSize;
|
|
||||||
bytesRemaining -= chunkSize;
|
bytesRemaining -= chunkSize;
|
||||||
}
|
}
|
||||||
file->lastRead = bufferSize;
|
file->lastRead = bufferSize;
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ errorret_t displayUpdate(void) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
errorChain(sceneRender());
|
errorChain(sceneRender());
|
||||||
|
errorChain(uiRender());
|
||||||
|
|
||||||
// Finish up
|
// Finish up
|
||||||
screenUnbind();
|
screenUnbind();
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ errorret_t engineUpdate(void) {
|
|||||||
errorChain(sceneUpdate());
|
errorChain(sceneUpdate());
|
||||||
errorChain(assetUpdate());
|
errorChain(assetUpdate());
|
||||||
errorChain(uiUpdate());
|
errorChain(uiUpdate());
|
||||||
|
errorChain(moduleSceneLateUpdateCurrent());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render
|
// Render
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ void eventSubscribe(event_t *event, eventcallback_t callback, void *user) {
|
|||||||
assertNotNull(event, "event must not be NULL");
|
assertNotNull(event, "event must not be NULL");
|
||||||
assertNotNull(callback, "callback must not be NULL");
|
assertNotNull(callback, "callback must not be NULL");
|
||||||
|
|
||||||
// Ensure callback isn't already susbcribed
|
// Ensure this exact (callback, user) pair isn't already subscribed --
|
||||||
|
// matching on callback alone would wrongly reject the common case of the
|
||||||
|
// same callback subscribing on behalf of two different owners (e.g. two
|
||||||
|
// assetbatch_t's both waiting on the same cached asset entry).
|
||||||
for(uint32_t i = 0; i < event->count; i++) {
|
for(uint32_t i = 0; i < event->count; i++) {
|
||||||
if(event->callbacks[i] != callback) continue;
|
if(event->callbacks[i] != callback) continue;
|
||||||
|
void *existingUser = event->users ? event->users[i] : NULL;
|
||||||
|
if(existingUser != user) continue;
|
||||||
assertUnreachable("Callback already registered, cannot subscribe twice.");
|
assertUnreachable("Callback already registered, cannot subscribe twice.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +52,14 @@ void eventSubscribe(event_t *event, eventcallback_t callback, void *user) {
|
|||||||
event->count++;
|
event->count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
void eventUnsubscribe(event_t *event, eventcallback_t callback) {
|
void eventUnsubscribe(event_t *event, eventcallback_t callback, void *user) {
|
||||||
assertNotNull(event, "event must not be NULL");
|
assertNotNull(event, "event must not be NULL");
|
||||||
assertNotNull(callback, "callback must not be NULL");
|
assertNotNull(callback, "callback must not be NULL");
|
||||||
|
|
||||||
for(uint32_t i = 0; i < event->count; i++) {
|
for(uint32_t i = 0; i < event->count; i++) {
|
||||||
if(event->callbacks[i] != callback) continue;
|
if(event->callbacks[i] != callback) continue;
|
||||||
|
void *existingUser = event->users ? event->users[i] : NULL;
|
||||||
|
if(existingUser != user) continue;
|
||||||
|
|
||||||
uint32_t last = event->count - 1;
|
uint32_t last = event->count - 1;
|
||||||
if(i != last) {
|
if(i != last) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user