From b639bc6c4fc6a508afb6486cafbb7174c82f38de Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Fri, 14 Aug 2026 09:05:11 -0500 Subject: [PATCH] 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 --- assets/scripts/Button.js | 49 +++++ assets/scripts/Checkbox.js | 61 ++++++ assets/scripts/Confirm.js | 110 +++++++++++ assets/scripts/ConsoleOverlay.js | 60 ++++++ assets/scripts/Crop.js | 64 ++++++ assets/scripts/Dropdown.js | 68 +++++++ assets/scripts/FpsCounter.js | 43 ++++ assets/scripts/Fullbox.js | 98 +++++++++ assets/scripts/Menu.js | 187 ++++++++++++++++++ assets/scripts/OverworldScene.js | 37 ++-- assets/scripts/Settings.js | 96 +++++++++ assets/scripts/SettingsGeneral.js | 48 +++++ assets/scripts/SettingsInput.js | 46 +++++ assets/scripts/Slider.js | 94 +++++++++ assets/scripts/Tab.js | 45 +++++ assets/scripts/TestUIElement.js | 56 ++++++ src/dusk/script/module/CMakeLists.txt | 3 + .../script/module/console/moduleconsole.c | 37 ++++ .../script/module/console/moduleconsole.h | 6 +- src/dusk/script/module/input/moduleinput.c | 13 ++ src/dusk/script/module/input/moduleinput.h | 3 +- src/dusk/script/module/locale/CMakeLists.txt | 9 + src/dusk/script/module/locale/modulelocale.c | 56 ++++++ src/dusk/script/module/locale/modulelocale.h | 22 +++ src/dusk/script/module/modulebase.h | 37 ++++ src/dusk/script/module/modulelist.c | 9 + src/dusk/script/module/modulelist.h | 6 +- src/dusk/script/module/save/CMakeLists.txt | 9 + src/dusk/script/module/save/modulesave.c | 26 +++ src/dusk/script/module/save/modulesave.h | 20 ++ src/dusk/script/module/screen/CMakeLists.txt | 9 + src/dusk/script/module/screen/modulescreen.c | 53 +++++ src/dusk/script/module/screen/modulescreen.h | 23 +++ src/dusk/script/module/time/moduletime.c | 11 ++ src/dusk/script/module/time/moduletime.h | 9 +- src/dusk/script/module/ui/modulelabel.c | 16 ++ src/dusk/script/module/ui/modulelabel.h | 2 +- src/dusk/script/module/ui/modulerectangle.c | 33 +--- src/dusk/script/module/ui/moduleui.c | 29 +++ src/dusk/script/module/ui/moduleui.h | 20 +- src/dusk/script/module/ui/moduleuielement.c | 126 ++++++++---- src/dusk/script/module/ui/moduleuielement.h | 11 +- src/dusk/ui/ui.c | 37 +++- src/dusk/ui/ui.h | 24 +++ src/dusk/ui/uielement.c | 25 ++- src/dusk/ui/uielement.h | 11 ++ src/dusk/ui/widget/uilabel.c | 10 + src/dusk/ui/widget/uilabel.h | 8 + src/dusk/ui/widget/uirectangle.c | 1 + test/script/CMakeLists.txt | 8 + test/script/test_moduleconsole.c | 70 +++++++ test/script/test_moduleinput.c | 17 ++ test/script/test_modulelocale.c | 70 +++++++ test/script/test_modulesave.c | 59 ++++++ test/script/test_modulescreen.c | 73 +++++++ test/script/test_moduletime.c | 71 +++++++ test/script/test_moduleui.c | 84 ++++++++ test/script/test_moduleuielement.c | 128 ++++++++++++ types/ui/label.d.ts | 2 + types/ui/uielement.d.ts | 15 ++ 60 files changed, 2369 insertions(+), 104 deletions(-) create mode 100644 assets/scripts/Button.js create mode 100644 assets/scripts/Checkbox.js create mode 100644 assets/scripts/Confirm.js create mode 100644 assets/scripts/ConsoleOverlay.js create mode 100644 assets/scripts/Crop.js create mode 100644 assets/scripts/Dropdown.js create mode 100644 assets/scripts/FpsCounter.js create mode 100644 assets/scripts/Fullbox.js create mode 100644 assets/scripts/Menu.js create mode 100644 assets/scripts/Settings.js create mode 100644 assets/scripts/SettingsGeneral.js create mode 100644 assets/scripts/SettingsInput.js create mode 100644 assets/scripts/Slider.js create mode 100644 assets/scripts/Tab.js create mode 100644 assets/scripts/TestUIElement.js create mode 100644 src/dusk/script/module/locale/CMakeLists.txt create mode 100644 src/dusk/script/module/locale/modulelocale.c create mode 100644 src/dusk/script/module/locale/modulelocale.h create mode 100644 src/dusk/script/module/save/CMakeLists.txt create mode 100644 src/dusk/script/module/save/modulesave.c create mode 100644 src/dusk/script/module/save/modulesave.h create mode 100644 src/dusk/script/module/screen/CMakeLists.txt create mode 100644 src/dusk/script/module/screen/modulescreen.c create mode 100644 src/dusk/script/module/screen/modulescreen.h create mode 100644 test/script/test_modulelocale.c create mode 100644 test/script/test_modulesave.c create mode 100644 test/script/test_modulescreen.c create mode 100644 test/script/test_moduletime.c diff --git a/assets/scripts/Button.js b/assets/scripts/Button.js new file mode 100644 index 00000000..c494704b --- /dev/null +++ b/assets/scripts/Button.js @@ -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; diff --git a/assets/scripts/Checkbox.js b/assets/scripts/Checkbox.js new file mode 100644 index 00000000..e187ea51 --- /dev/null +++ b/assets/scripts/Checkbox.js @@ -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; diff --git a/assets/scripts/Confirm.js b/assets/scripts/Confirm.js new file mode 100644 index 00000000..ec529bd6 --- /dev/null +++ b/assets/scripts/Confirm.js @@ -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; diff --git a/assets/scripts/ConsoleOverlay.js b/assets/scripts/ConsoleOverlay.js new file mode 100644 index 00000000..122a0d41 --- /dev/null +++ b/assets/scripts/ConsoleOverlay.js @@ -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; diff --git a/assets/scripts/Crop.js b/assets/scripts/Crop.js new file mode 100644 index 00000000..22f4bb6c --- /dev/null +++ b/assets/scripts/Crop.js @@ -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; diff --git a/assets/scripts/Dropdown.js b/assets/scripts/Dropdown.js new file mode 100644 index 00000000..4781e08b --- /dev/null +++ b/assets/scripts/Dropdown.js @@ -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; diff --git a/assets/scripts/FpsCounter.js b/assets/scripts/FpsCounter.js new file mode 100644 index 00000000..dc590b45 --- /dev/null +++ b/assets/scripts/FpsCounter.js @@ -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; diff --git a/assets/scripts/Fullbox.js b/assets/scripts/Fullbox.js new file mode 100644 index 00000000..b716a67a --- /dev/null +++ b/assets/scripts/Fullbox.js @@ -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; diff --git a/assets/scripts/Menu.js b/assets/scripts/Menu.js new file mode 100644 index 00000000..f130ac07 --- /dev/null +++ b/assets/scripts/Menu.js @@ -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; diff --git a/assets/scripts/OverworldScene.js b/assets/scripts/OverworldScene.js index 5e4f8284..e6567d19 100644 --- a/assets/scripts/OverworldScene.js +++ b/assets/scripts/OverworldScene.js @@ -1,6 +1,9 @@ var Player = require('./Player.js'); var PlayerCamera = require('./PlayerCamera.js'); var TestPlane = require('./TestPlane.js'); +var TestUIElement = require('./TestUIElement.js'); +var FpsCounter = require('./FpsCounter.js'); +var ConsoleOverlay = require('./ConsoleOverlay.js'); class OverworldScene { constructor() { @@ -11,15 +14,14 @@ class OverworldScene { this.plane = new TestPlane(); this.camera = new PlayerCamera(this.player); - this.rect = new Rectangle(); - this.rect.color = { r: 255, g: 0, b: 0, a: 255 }; - this.rect.width = 100; - this.rect.height = 100; - UI.add(this.rect); - - this.label = new Label(); - this.label.text = 'Hello World!'; - this.rect.add(this.label); + this.testUi = new TestUIElement(); + UI.add(this.testUi); + + this.fpsCounter = new FpsCounter(); + UI.addOverlay(this.fpsCounter); + + this.consoleOverlay = new ConsoleOverlay(); + UI.addOverlay(this.consoleOverlay); } lateUpdate() { @@ -31,16 +33,19 @@ class OverworldScene { this.player.dispose(); this.plane.dispose(); - // rect.dispose() cascades to label (added as its child) and removes - // itself from UI's render roots -- no separate UI.remove()/dispose() - // needed for either. - this.rect.dispose(); + // testUi.dispose() cascades to its background/label children and + // removes itself from UI's render roots -- no separate UI.remove() + // needed. + this.testUi.dispose(); + this.fpsCounter.dispose(); + this.consoleOverlay.dispose(); - this.camera = null; + this.camera = null; this.player = null; this.plane = null; - this.label = null; - this.rect = null; + this.testUi = null; + this.fpsCounter = null; + this.consoleOverlay = null; } } diff --git a/assets/scripts/Settings.js b/assets/scripts/Settings.js new file mode 100644 index 00000000..624e7d1f --- /dev/null +++ b/assets/scripts/Settings.js @@ -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; diff --git a/assets/scripts/SettingsGeneral.js b/assets/scripts/SettingsGeneral.js new file mode 100644 index 00000000..b3444e5a --- /dev/null +++ b/assets/scripts/SettingsGeneral.js @@ -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; diff --git a/assets/scripts/SettingsInput.js b/assets/scripts/SettingsInput.js new file mode 100644 index 00000000..e8ae5512 --- /dev/null +++ b/assets/scripts/SettingsInput.js @@ -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; diff --git a/assets/scripts/Slider.js b/assets/scripts/Slider.js new file mode 100644 index 00000000..ba7ea603 --- /dev/null +++ b/assets/scripts/Slider.js @@ -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; diff --git a/assets/scripts/Tab.js b/assets/scripts/Tab.js new file mode 100644 index 00000000..602c5618 --- /dev/null +++ b/assets/scripts/Tab.js @@ -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; diff --git a/assets/scripts/TestUIElement.js b/assets/scripts/TestUIElement.js new file mode 100644 index 00000000..d4f9dae4 --- /dev/null +++ b/assets/scripts/TestUIElement.js @@ -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; diff --git a/src/dusk/script/module/CMakeLists.txt b/src/dusk/script/module/CMakeLists.txt index 856d7105..b03eb0d3 100644 --- a/src/dusk/script/module/CMakeLists.txt +++ b/src/dusk/script/module/CMakeLists.txt @@ -15,4 +15,7 @@ add_subdirectory(display) add_subdirectory(time) add_subdirectory(console) add_subdirectory(input) +add_subdirectory(screen) +add_subdirectory(locale) +add_subdirectory(save) add_subdirectory(ui) diff --git a/src/dusk/script/module/console/moduleconsole.c b/src/dusk/script/module/console/moduleconsole.c index 36233f93..99cea0e4 100644 --- a/src/dusk/script/module/console/moduleconsole.c +++ b/src/dusk/script/module/console/moduleconsole.c @@ -35,9 +35,46 @@ moduleBaseFunction(moduleConsolePrint) { 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) { jerry_value_t consoleObj = jerry_object(); 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); jerry_value_free(consoleObj); } diff --git a/src/dusk/script/module/console/moduleconsole.h b/src/dusk/script/module/console/moduleconsole.h index 3b8512b0..db476bcd 100644 --- a/src/dusk/script/module/console/moduleconsole.h +++ b/src/dusk/script/module/console/moduleconsole.h @@ -11,7 +11,11 @@ * Registers the global `Console` object, exposing `Console.print(...)`, * which stringifies and space-joins its arguments (like console.log) and * 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); diff --git a/src/dusk/script/module/input/moduleinput.c b/src/dusk/script/module/input/moduleinput.c index c4905d0c..695286c8 100644 --- a/src/dusk/script/module/input/moduleinput.c +++ b/src/dusk/script/module/input/moduleinput.c @@ -54,6 +54,16 @@ moduleBaseFunction(moduleInputReleasedFn) { return jerry_boolean(inputReleased((inputbind_t)moduleBaseArgInt(0))); } +moduleBaseFunction(moduleInputGetDeadzone) { + return jerry_number(INPUT.deadzone); +} + +moduleBaseFunction(moduleInputSetDeadzone) { + moduleBaseRequireArgs(1); moduleBaseRequireNumber(0); + INPUT.deadzone = moduleBaseArgFloat(0); + return jerry_undefined(); +} + void moduleInputInit(void) { jerry_value_t inputObj = jerry_object(); moduleBaseDefineMethod(inputObj, "bind", moduleInputBindFn); @@ -63,6 +73,9 @@ void moduleInputInit(void) { moduleBaseDefineMethod(inputObj, "wasDown", moduleInputWasDownFn); moduleBaseDefineMethod(inputObj, "pressed", moduleInputPressedFn); moduleBaseDefineMethod(inputObj, "released", moduleInputReleasedFn); + moduleBaseDefineProperty( + inputObj, "deadzone", moduleInputGetDeadzone, moduleInputSetDeadzone + ); moduleBaseSetValue("Input", inputObj); jerry_value_free(inputObj); diff --git a/src/dusk/script/module/input/moduleinput.h b/src/dusk/script/module/input/moduleinput.h index a6490342..a8da7792 100644 --- a/src/dusk/script/module/input/moduleinput.h +++ b/src/dusk/script/module/input/moduleinput.h @@ -9,7 +9,8 @@ /** * Registers the global `Input` object (bind/value/lastValue/isDown/wasDown/ - * pressed/released), plus two constant lookup objects: `InputBind` (the + * pressed/released, plus a read/write `deadzone` property over + * INPUT.deadzone), plus two constant lookup objects: `InputBind` (the * inputbind_t action enum, e.g. InputBind.UP) and `InputButton` (every * named physical button in the compiled platform's INPUT_BUTTON_DATA * table, e.g. InputButton.SELECT -- the exact set varies per platform). diff --git a/src/dusk/script/module/locale/CMakeLists.txt b/src/dusk/script/module/locale/CMakeLists.txt new file mode 100644 index 00000000..7eb305af --- /dev/null +++ b/src/dusk/script/module/locale/CMakeLists.txt @@ -0,0 +1,9 @@ +# 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 + modulelocale.c +) diff --git a/src/dusk/script/module/locale/modulelocale.c b/src/dusk/script/module/locale/modulelocale.c new file mode 100644 index 00000000..9115bb39 --- /dev/null +++ b/src/dusk/script/module/locale/modulelocale.c @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "modulelocale.h" +#include "script/module/modulebase.h" +#include "locale/localemanager.h" +#include "locale/localeinfo.h" +#include "util/string.h" + +#define MODULE_LOCALE_NAME_MAX 32 + +static const localeinfo_t *MODULE_LOCALE_KNOWN[] = { + &LOCALE_EN_US, + &LOCALE_JP_JP, + &LOCALE_ES_MX, + NULL +}; + +moduleBaseFunction(moduleLocaleGetCurrent) { + if(!LOCALE.locale) return jerry_string_sz(""); + return jerry_string_sz(LOCALE.locale->name); +} + +moduleBaseFunction(moduleLocaleSet) { + moduleBaseRequireArgs(1); moduleBaseRequireString(0); + + char_t name[MODULE_LOCALE_NAME_MAX]; + moduleBaseToString(args[0], name, sizeof(name)); + + for(const localeinfo_t **info = MODULE_LOCALE_KNOWN; *info; info++) { + if(stringCompare((*info)->name, name) != 0) continue; + + errorret_t ret = localeManagerSetLocale(*info); + if(errorIsNotOk(ret)) return moduleBaseThrowError(ret); + return jerry_undefined(); + } + + return moduleBaseThrow("Locale.set: unknown locale name"); +} + +void moduleLocaleInit(void) { + jerry_value_t localeObj = jerry_object(); + moduleBaseDefineProperty( + localeObj, "current", moduleLocaleGetCurrent, NULL + ); + moduleBaseDefineMethod(localeObj, "set", moduleLocaleSet); + moduleBaseSetValue("Locale", localeObj); + jerry_value_free(localeObj); +} + +void moduleLocaleDispose(void) { +} diff --git a/src/dusk/script/module/locale/modulelocale.h b/src/dusk/script/module/locale/modulelocale.h new file mode 100644 index 00000000..156e5f2c --- /dev/null +++ b/src/dusk/script/module/locale/modulelocale.h @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once + +/** + * Registers the global `Locale` object, exposing read-only `current` + * (the active locale's name, e.g. "en-US") and `set(name)` (switches the + * active locale by name -- throws if name doesn't match a known + * localeinfo_t) over the engine's locale system (see + * locale/localemanager.h). + */ +void moduleLocaleInit(void); + +/** + * Disposes the locale module's script resources. + */ +void moduleLocaleDispose(void); diff --git a/src/dusk/script/module/modulebase.h b/src/dusk/script/module/modulebase.h index 42309c39..4a347001 100644 --- a/src/dusk/script/module/modulebase.h +++ b/src/dusk/script/module/modulebase.h @@ -10,6 +10,7 @@ #include "assert/assert.h" #include "util/string.h" #include "util/memory.h" +#include "display/color.h" #include #include @@ -460,3 +461,39 @@ static inline jerry_value_t moduleBaseVec3ToObject(const vec3 v) { return obj; } + +/** + * Wraps a color_t as a plain JS {r, g, b, a} object. + */ +static inline jerry_value_t moduleBaseColorToObject(const color_t c) { + jerry_value_t obj = jerry_object(); + moduleBaseObjectSetNumber(obj, "r", c.r); + moduleBaseObjectSetNumber(obj, "g", c.g); + moduleBaseObjectSetNumber(obj, "b", c.b); + moduleBaseObjectSetNumber(obj, "a", c.a); + return obj; +} + +/** + * Reads a plain JS {r, g, b, a} object into a color_t. Any missing + * channel keeps its COLOR_WHITE default. + */ +static inline color_t moduleBaseColorFromObject(const jerry_value_t obj) { + color_t c = COLOR_WHITE; + + jerry_value_t rVal = moduleBaseGetProp(obj, "r"); + jerry_value_t gVal = moduleBaseGetProp(obj, "g"); + jerry_value_t bVal = moduleBaseGetProp(obj, "b"); + jerry_value_t aVal = moduleBaseGetProp(obj, "a"); + + if(jerry_value_is_number(rVal)) c.r = (colorchannel8_t)moduleBaseValueInt(rVal); + if(jerry_value_is_number(gVal)) c.g = (colorchannel8_t)moduleBaseValueInt(gVal); + if(jerry_value_is_number(bVal)) c.b = (colorchannel8_t)moduleBaseValueInt(bVal); + if(jerry_value_is_number(aVal)) c.a = (colorchannel8_t)moduleBaseValueInt(aVal); + + jerry_value_free(rVal); + jerry_value_free(gVal); + jerry_value_free(bVal); + jerry_value_free(aVal); + return c; +} diff --git a/src/dusk/script/module/modulelist.c b/src/dusk/script/module/modulelist.c index e887d97c..134e3fe6 100644 --- a/src/dusk/script/module/modulelist.c +++ b/src/dusk/script/module/modulelist.c @@ -16,6 +16,9 @@ #include "script/module/scene/modulescene.h" #include "script/module/console/moduleconsole.h" #include "script/module/input/moduleinput.h" +#include "script/module/screen/modulescreen.h" +#include "script/module/locale/modulelocale.h" +#include "script/module/save/modulesave.h" #include "script/module/ui/moduleuielement.h" #include "script/module/ui/modulelabel.h" #include "script/module/ui/modulerectangle.h" @@ -28,6 +31,9 @@ void moduleListInit(void) { moduleTimeInit(); moduleConsoleInit(); moduleInputInit(); + moduleScreenInit(); + moduleLocaleInit(); + moduleSaveInit(); moduleUiElementInit(); moduleLabelInit(); moduleRectangleInit(); @@ -47,6 +53,9 @@ void moduleListDispose(void) { moduleRectangleDispose(); moduleLabelDispose(); moduleUiElementDispose(); + moduleSaveDispose(); + moduleLocaleDispose(); + moduleScreenDispose(); moduleInputDispose(); moduleConsoleDispose(); moduleTimeDispose(); diff --git a/src/dusk/script/module/modulelist.h b/src/dusk/script/module/modulelist.h index 928ebdbe..ee5d702a 100644 --- a/src/dusk/script/module/modulelist.h +++ b/src/dusk/script/module/modulelist.h @@ -9,9 +9,9 @@ /** * Registers every script module (Component + its typed per-type - * wrappers, Entity, Scene, require(), Mesh, Time, Console, Input, - * UIElement + Label/Rectangle + UI, the platform globals). Called once - * by scriptManagerInit(). + * wrappers, Entity, Scene, require(), Mesh, Time, Console, Input, Screen, + * Locale, Save, UIElement + Label/Rectangle + UI, the platform globals). + * Called once by scriptManagerInit(). */ void moduleListInit(void); diff --git a/src/dusk/script/module/save/CMakeLists.txt b/src/dusk/script/module/save/CMakeLists.txt new file mode 100644 index 00000000..9121bce6 --- /dev/null +++ b/src/dusk/script/module/save/CMakeLists.txt @@ -0,0 +1,9 @@ +# 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 + modulesave.c +) diff --git a/src/dusk/script/module/save/modulesave.c b/src/dusk/script/module/save/modulesave.c new file mode 100644 index 00000000..1b1212e0 --- /dev/null +++ b/src/dusk/script/module/save/modulesave.c @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "modulesave.h" +#include "script/module/modulebase.h" +#include "save/savesettings.h" + +moduleBaseFunction(moduleSaveWrite) { + errorret_t ret = saveSettingsWrite(); + if(errorIsNotOk(ret)) return moduleBaseThrowError(ret); + return jerry_undefined(); +} + +void moduleSaveInit(void) { + jerry_value_t saveObj = jerry_object(); + moduleBaseDefineMethod(saveObj, "write", moduleSaveWrite); + moduleBaseSetValue("Save", saveObj); + jerry_value_free(saveObj); +} + +void moduleSaveDispose(void) { +} diff --git a/src/dusk/script/module/save/modulesave.h b/src/dusk/script/module/save/modulesave.h new file mode 100644 index 00000000..6d8d0700 --- /dev/null +++ b/src/dusk/script/module/save/modulesave.h @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once + +/** + * Registers the global `Save` object, exposing `write()`, which gathers + * all game settings from their owning runtime state and persists them + * (see save/savesettings.h's saveSettingsWrite()). + */ +void moduleSaveInit(void); + +/** + * Disposes the save module's script resources. + */ +void moduleSaveDispose(void); diff --git a/src/dusk/script/module/screen/CMakeLists.txt b/src/dusk/script/module/screen/CMakeLists.txt new file mode 100644 index 00000000..5abcbbdb --- /dev/null +++ b/src/dusk/script/module/screen/CMakeLists.txt @@ -0,0 +1,9 @@ +# 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 + modulescreen.c +) diff --git a/src/dusk/script/module/screen/modulescreen.c b/src/dusk/script/module/screen/modulescreen.c new file mode 100644 index 00000000..7522f8df --- /dev/null +++ b/src/dusk/script/module/screen/modulescreen.c @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "modulescreen.h" +#include "script/module/modulebase.h" +#include "display/screen/screen.h" + +moduleBaseFunction(moduleScreenGetWidth) { + return jerry_number(SCREEN.width); +} + +moduleBaseFunction(moduleScreenGetHeight) { + return jerry_number(SCREEN.height); +} + +moduleBaseFunction(moduleScreenGetScanX) { + return jerry_number(SCREEN.scanX); +} + +moduleBaseFunction(moduleScreenGetScanY) { + return jerry_number(SCREEN.scanY); +} + +moduleBaseFunction(moduleScreenGetScanWidth) { + return jerry_number(SCREEN.scanWidth); +} + +moduleBaseFunction(moduleScreenGetScanHeight) { + return jerry_number(SCREEN.scanHeight); +} + +void moduleScreenInit(void) { + jerry_value_t screenObj = jerry_object(); + moduleBaseDefineProperty(screenObj, "width", moduleScreenGetWidth, NULL); + moduleBaseDefineProperty(screenObj, "height", moduleScreenGetHeight, NULL); + moduleBaseDefineProperty(screenObj, "scanX", moduleScreenGetScanX, NULL); + moduleBaseDefineProperty(screenObj, "scanY", moduleScreenGetScanY, NULL); + moduleBaseDefineProperty( + screenObj, "scanWidth", moduleScreenGetScanWidth, NULL + ); + moduleBaseDefineProperty( + screenObj, "scanHeight", moduleScreenGetScanHeight, NULL + ); + moduleBaseSetValue("Screen", screenObj); + jerry_value_free(screenObj); +} + +void moduleScreenDispose(void) { +} diff --git a/src/dusk/script/module/screen/modulescreen.h b/src/dusk/script/module/screen/modulescreen.h new file mode 100644 index 00000000..6265affc --- /dev/null +++ b/src/dusk/script/module/screen/modulescreen.h @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once + +/** + * Registers the global `Screen` object, exposing read-only `width`/ + * `height` (calculated viewport dimensions) and `scanX`/`scanY`/ + * `scanWidth`/`scanHeight` (the overscan-safe area within the viewport -- + * defaults to the full viewport, letterboxed/pillarboxed on platforms + * that need it) as live getters over the engine's SCREEN state (see + * display/screen/screen.h). + */ +void moduleScreenInit(void); + +/** + * Disposes the screen module's script resources. + */ +void moduleScreenDispose(void); diff --git a/src/dusk/script/module/time/moduletime.c b/src/dusk/script/module/time/moduletime.c index e184b9b5..2d373183 100644 --- a/src/dusk/script/module/time/moduletime.c +++ b/src/dusk/script/module/time/moduletime.c @@ -17,10 +17,21 @@ moduleBaseFunction(moduleTimeGetTime) { return jerry_number(TIME.time); } +moduleBaseFunction(moduleTimeGetRenderDelta) { + #ifdef DUSK_TIME_DYNAMIC + return jerry_number(TIME.dynamicDelta); + #else + return jerry_number(TIME.delta); + #endif +} + void moduleTimeInit(void) { jerry_value_t timeObj = jerry_object(); moduleBaseDefineProperty(timeObj, "delta", moduleTimeGetDelta, NULL); moduleBaseDefineProperty(timeObj, "time", moduleTimeGetTime, NULL); + moduleBaseDefineProperty( + timeObj, "renderDelta", moduleTimeGetRenderDelta, NULL + ); moduleBaseSetValue("Time", timeObj); jerry_value_free(timeObj); } diff --git a/src/dusk/script/module/time/moduletime.h b/src/dusk/script/module/time/moduletime.h index c9be15d4..c47c79a0 100644 --- a/src/dusk/script/module/time/moduletime.h +++ b/src/dusk/script/module/time/moduletime.h @@ -9,8 +9,13 @@ /** * Registers the global `Time` object, exposing `Time.delta` and - * `Time.time` as live getters over the engine's TIME state (see - * time/time.h). Values always reflect the most recent timeUpdate(). + * `Time.time` (fixed-timestep, only advance on the one timeUpdate() call + * per tick that crosses a DUSK_TIME_STEP boundary) and `Time.renderDelta` + * (real elapsed time since the previous timeUpdate() call, i.e. + * TIME.dynamicDelta under DUSK_TIME_DYNAMIC or plain TIME.delta + * otherwise -- for per-real-frame effects like FPS counters or tweens + * that shouldn't be gated to the fixed tick) as live getters over the + * engine's TIME state (see time/time.h). */ void moduleTimeInit(void); diff --git a/src/dusk/script/module/ui/modulelabel.c b/src/dusk/script/module/ui/modulelabel.c index bce9bcd1..a049a148 100644 --- a/src/dusk/script/module/ui/modulelabel.c +++ b/src/dusk/script/module/ui/modulelabel.c @@ -35,6 +35,19 @@ moduleBaseFunction(moduleLabelSetText) { return jerry_undefined(); } +moduleBaseFunction(moduleLabelGetColor) { + moduleBaseGetOrReturn(moduleuielementhandle_t, h, moduleUiElementGet); + return moduleBaseColorToObject(UI_ELEMENTS[h->id].label.color); +} + +moduleBaseFunction(moduleLabelSetColor) { + moduleBaseGetOrReturn(moduleuielementhandle_t, h, moduleUiElementGet); + moduleBaseRequireArgs(1); moduleBaseRequireObject(0); + + uiLabelSetColor(h->id, moduleBaseColorFromObject(args[0])); + return jerry_undefined(); +} + void moduleLabelInit(void) { scriptProtoInit( &MODULE_LABEL_PROTO, @@ -49,6 +62,9 @@ void moduleLabelInit(void) { scriptProtoDefineProp( &MODULE_LABEL_PROTO, "text", moduleLabelGetText, moduleLabelSetText ); + scriptProtoDefineProp( + &MODULE_LABEL_PROTO, "color", moduleLabelGetColor, moduleLabelSetColor + ); } void moduleLabelDispose(void) { diff --git a/src/dusk/script/module/ui/modulelabel.h b/src/dusk/script/module/ui/modulelabel.h index 1c7ce28a..23da3339 100644 --- a/src/dusk/script/module/ui/modulelabel.h +++ b/src/dusk/script/module/ui/modulelabel.h @@ -12,7 +12,7 @@ * UI_ELEMENT_TYPE_LABEL pool element. Its prototype chains to * UIElement.prototype (see moduleuielement.h), so x/y/worldX/worldY/ * parent/render/dispose all work on a Label unmodified; Label itself - * only adds a `text` property (read/write). + * adds `text` and `color` properties (both read/write). */ void moduleLabelInit(void); diff --git a/src/dusk/script/module/ui/modulerectangle.c b/src/dusk/script/module/ui/modulerectangle.c index d2eab690..74688a7c 100644 --- a/src/dusk/script/module/ui/modulerectangle.c +++ b/src/dusk/script/module/ui/modulerectangle.c @@ -13,35 +13,6 @@ static scriptproto_t MODULE_RECTANGLE_PROTO; -static jerry_value_t moduleRectangleColorToObject(const color_t c) { - jerry_value_t obj = jerry_object(); - moduleBaseObjectSetNumber(obj, "r", c.r); - moduleBaseObjectSetNumber(obj, "g", c.g); - moduleBaseObjectSetNumber(obj, "b", c.b); - moduleBaseObjectSetNumber(obj, "a", c.a); - return obj; -} - -static color_t moduleRectangleColorFromObject(const jerry_value_t obj) { - color_t c = COLOR_WHITE; - - jerry_value_t rVal = moduleBaseGetProp(obj, "r"); - jerry_value_t gVal = moduleBaseGetProp(obj, "g"); - jerry_value_t bVal = moduleBaseGetProp(obj, "b"); - jerry_value_t aVal = moduleBaseGetProp(obj, "a"); - - if(jerry_value_is_number(rVal)) c.r = (colorchannel8_t)moduleBaseValueInt(rVal); - if(jerry_value_is_number(gVal)) c.g = (colorchannel8_t)moduleBaseValueInt(gVal); - if(jerry_value_is_number(bVal)) c.b = (colorchannel8_t)moduleBaseValueInt(bVal); - if(jerry_value_is_number(aVal)) c.a = (colorchannel8_t)moduleBaseValueInt(aVal); - - jerry_value_free(rVal); - jerry_value_free(gVal); - jerry_value_free(bVal); - jerry_value_free(aVal); - return c; -} - moduleBaseFunction(moduleRectangleConstructor) { errorret_t ret = moduleUiElementConstructShared( callInfo->this_value, UI_ELEMENT_TYPE_RECTANGLE @@ -80,14 +51,14 @@ moduleBaseFunction(moduleRectangleSetHeight) { moduleBaseFunction(moduleRectangleGetColor) { moduleBaseGetOrReturn(moduleuielementhandle_t, h, moduleUiElementGet); - return moduleRectangleColorToObject(UI_ELEMENTS[h->id].rectangle.color); + return moduleBaseColorToObject(UI_ELEMENTS[h->id].rectangle.color); } moduleBaseFunction(moduleRectangleSetColor) { moduleBaseGetOrReturn(moduleuielementhandle_t, h, moduleUiElementGet); moduleBaseRequireArgs(1); moduleBaseRequireObject(0); - uiRectangleSetColor(h->id, moduleRectangleColorFromObject(args[0])); + uiRectangleSetColor(h->id, moduleBaseColorFromObject(args[0])); return jerry_undefined(); } diff --git a/src/dusk/script/module/ui/moduleui.c b/src/dusk/script/module/ui/moduleui.c index 167b8f70..00048416 100644 --- a/src/dusk/script/module/ui/moduleui.c +++ b/src/dusk/script/module/ui/moduleui.c @@ -34,10 +34,39 @@ moduleBaseFunction(moduleUiRemoveFn) { return jerry_undefined(); } +moduleBaseFunction(moduleUiAddOverlayFn) { + moduleBaseRequireArgs(1); moduleBaseRequireObject(0); + + moduleuielementhandle_t *h = moduleUiElementGetFromValue(args[0]); + if(!h) return moduleBaseThrow("Expected a UIElement"); + + // A root/overlay root and a child are mutually exclusive, same as + // UI.add() -- promoting an existing child detaches it from its parent + // first. + uiElementSetParent(h->id, UI_ELEMENT_ID_INVALID); + + if(!uiOverlayRootAdd(h->id)) { + return moduleBaseThrow("UI overlay root capacity exceeded"); + } + return jerry_undefined(); +} + +moduleBaseFunction(moduleUiRemoveOverlayFn) { + moduleBaseRequireArgs(1); moduleBaseRequireObject(0); + + moduleuielementhandle_t *h = moduleUiElementGetFromValue(args[0]); + if(!h) return moduleBaseThrow("Expected a UIElement"); + + uiOverlayRootRemove(h->id); + return jerry_undefined(); +} + void moduleUiInit(void) { jerry_value_t uiObj = jerry_object(); moduleBaseDefineMethod(uiObj, "add", moduleUiAddFn); moduleBaseDefineMethod(uiObj, "remove", moduleUiRemoveFn); + moduleBaseDefineMethod(uiObj, "addOverlay", moduleUiAddOverlayFn); + moduleBaseDefineMethod(uiObj, "removeOverlay", moduleUiRemoveOverlayFn); moduleBaseSetValue("UI", uiObj); jerry_value_free(uiObj); } diff --git a/src/dusk/script/module/ui/moduleui.h b/src/dusk/script/module/ui/moduleui.h index 5d5adfd1..fe2189b2 100644 --- a/src/dusk/script/module/ui/moduleui.h +++ b/src/dusk/script/module/ui/moduleui.h @@ -8,13 +8,19 @@ #pragma once /** - * Registers the global `UI` object, exposing `UI.add(element)` and - * `UI.remove(element)`. Both take a UIElement (or Label/Rectangle/etc.) - * instance and add/remove it from the engine's render roots (UI.root[] - * in ui/ui.h) -- root elements get their render() called once per frame - * by uiRender(); everything else only renders as part of a root's - * children (see UIElement.add()). A root and a child are mutually - * exclusive: UI.add() detaches the element from any parent first. + * Registers the global `UI` object, exposing `UI.add(element)`/ + * `UI.remove(element)` and `UI.addOverlay(element)`/ + * `UI.removeOverlay(element)`. All four take a UIElement (or Label/ + * Rectangle/etc.) instance; add/remove manage the engine's normal render + * roots (UI.root[] in ui/ui.h), addOverlay/removeOverlay manage a + * second root array (UI.overlayRoot[]) that's always drawn/updated + * strictly after every normal root, for things that must always sit on + * top (debug overlays, screen fades/transitions). Root/overlay-root + * elements get render()/update() called once per frame/tick by + * uiRender()/uiUpdate(); everything else only renders as part of a + * root's children (see UIElement.add()). A root (of either kind) and a + * child are mutually exclusive: UI.add()/UI.addOverlay() detach the + * element from any parent first. */ void moduleUiInit(void); diff --git a/src/dusk/script/module/ui/moduleuielement.c b/src/dusk/script/module/ui/moduleuielement.c index eaaff4b7..265d3f13 100644 --- a/src/dusk/script/module/ui/moduleuielement.c +++ b/src/dusk/script/module/ui/moduleuielement.c @@ -17,10 +17,12 @@ scriptproto_t MODULE_UI_ELEMENT_PROTO; // given element id. Indices with no registered instance hold undefined. static jerry_value_t UI_ELEMENT_JS_INSTANCES[UI_ELEMENT_COUNT_MAX]; -// Own (instance, not prototype) property that a construction-time -// override wrap stashes the (sub)class's real render() under -- see -// moduleUiElementConstructShared and moduleUiElementRenderScripted. +// Own (instance, not prototype) properties that a construction-time +// override wrap stashes the (sub)class's real render()/update() under -- +// see moduleUiElementConstructShared and moduleUiElementRenderScripted/ +// moduleUiElementUpdateScripted. #define MODULE_UI_ELEMENT_USER_RENDER_PROP "__uiElementUserRender" +#define MODULE_UI_ELEMENT_USER_UPDATE_PROP "__uiElementUserUpdate" static bool_t moduleUiElementStrictEqual( const jerry_value_t a, @@ -38,6 +40,12 @@ static jerry_value_t moduleUiElementRenderMethod( const jerry_length_t argc ); +static jerry_value_t moduleUiElementUpdateMethod( + const jerry_call_info_t *callInfo, + const jerry_value_t args[], + const jerry_length_t argc +); + // UI_ELEMENT_TYPE_SCRIPTED's render callback: calls the (sub)class's own // render(), stashed at construction time under // MODULE_UI_ELEMENT_USER_RENDER_PROP -- see moduleUiElementConstructShared. @@ -64,45 +72,67 @@ static void moduleUiElementRenderScripted(uielement_t *element) { jerry_value_free(renderFn); } -// Calling instance.render() (JS method-call syntax) only reaches native -// code if the property lookup actually resolves to -// UIElement.prototype.render -- if a (sub)class overrides render(), that -// override shadows it and callers bypass uiElementRender() entirely, -// which is what actually tracks parent/worldX/worldY. So if this -// instance's resolved render is anything other than the base one, stash -// the override under MODULE_UI_ELEMENT_USER_RENDER_PROP and shadow -// render itself (as an own property, just on this instance) with the -// native trampoline -- moduleUiElementRenderScripted then calls the -// stashed override from inside uiElementRender(), fully wired up. -static void moduleUiElementWrapUserRenderIfOverridden( - const jerry_value_t thisValue +// UI_ELEMENT_TYPE_SCRIPTED's update callback -- exact update-side twin of +// moduleUiElementRenderScripted, see there for the reasoning. +static void moduleUiElementUpdateScripted(uielement_t *element) { + jerry_value_t instance = UI_ELEMENT_JS_INSTANCES[element->id]; + jerry_value_t updateFn = jerry_value_is_object(instance) + ? moduleBaseGetProp(instance, MODULE_UI_ELEMENT_USER_UPDATE_PROP) + : jerry_undefined(); + + if(jerry_value_is_function(updateFn)) { + errorret_t ret = scriptManagerCallValue( + instance, updateFn, "UIElement", "update" + ); + errorCatch(errorPrint(ret)); + } else { + uiElementUpdateChildren(element); + } + + jerry_value_free(updateFn); +} + +// Calling instance.render()/update() (JS method-call syntax) only reaches +// native code if the property lookup actually resolves to +// UIElement.prototype's own (native) method -- if a (sub)class overrides +// it, that override shadows it and callers bypass uiElementRender()/ +// uiElementUpdate() entirely, which is what actually tracks parent/ +// worldX/worldY (for render) or reaches this element at all from the +// engine's per-tick/per-frame root walk (for update). So if this +// instance's resolved method is anything other than the base one, stash +// the override under stashProp and shadow methodName itself (as an own +// property, just on this instance) with trampoline -- +// moduleUiElementRenderScripted/moduleUiElementUpdateScripted then call +// the stashed override from inside uiElementRender()/uiElementUpdate(), +// fully wired up. +static void moduleUiElementWrapUserMethodIfOverridden( + const jerry_value_t thisValue, + const char_t *methodName, + const char_t *stashProp, + jerry_external_handler_t trampolineFn ) { - jerry_value_t resolvedRender = moduleBaseGetProp(thisValue, "render"); - jerry_value_t baseRender = moduleBaseGetProp( - MODULE_UI_ELEMENT_PROTO.prototype, "render" + jerry_value_t resolvedMethod = moduleBaseGetProp(thisValue, methodName); + jerry_value_t baseMethod = moduleBaseGetProp( + MODULE_UI_ELEMENT_PROTO.prototype, methodName ); if( - jerry_value_is_function(resolvedRender) && - !moduleUiElementStrictEqual(resolvedRender, baseRender) + jerry_value_is_function(resolvedMethod) && + !moduleUiElementStrictEqual(resolvedMethod, baseMethod) ) { - jerry_value_t userRenderKey = jerry_string_sz( - MODULE_UI_ELEMENT_USER_RENDER_PROP - ); - jerry_object_set(thisValue, userRenderKey, resolvedRender); - jerry_value_free(userRenderKey); + jerry_value_t stashKey = jerry_string_sz(stashProp); + jerry_object_set(thisValue, stashKey, resolvedMethod); + jerry_value_free(stashKey); - jerry_value_t trampoline = jerry_function_external( - moduleUiElementRenderMethod - ); - jerry_value_t renderKey = jerry_string_sz("render"); - jerry_object_set(thisValue, renderKey, trampoline); - jerry_value_free(renderKey); + jerry_value_t trampoline = jerry_function_external(trampolineFn); + jerry_value_t methodKey = jerry_string_sz(methodName); + jerry_object_set(thisValue, methodKey, trampoline); + jerry_value_free(methodKey); jerry_value_free(trampoline); } - jerry_value_free(baseRender); - jerry_value_free(resolvedRender); + jerry_value_free(baseMethod); + jerry_value_free(resolvedMethod); } errorret_t moduleUiElementConstructShared( @@ -119,7 +149,14 @@ errorret_t moduleUiElementConstructShared( thisValue, &MODULE_UI_ELEMENT_PROTO.info, inst ); UI_ELEMENT_JS_INSTANCES[inst->id] = jerry_value_copy(thisValue); - moduleUiElementWrapUserRenderIfOverridden(thisValue); + moduleUiElementWrapUserMethodIfOverridden( + thisValue, "render", MODULE_UI_ELEMENT_USER_RENDER_PROP, + moduleUiElementRenderMethod + ); + moduleUiElementWrapUserMethodIfOverridden( + thisValue, "update", MODULE_UI_ELEMENT_USER_UPDATE_PROP, + moduleUiElementUpdateMethod + ); jerry_value_t initFn = moduleBaseGetProp(thisValue, "init"); if(jerry_value_is_function(initFn)) { @@ -259,6 +296,18 @@ moduleBaseFunction(moduleUiElementRenderMethod) { return jerry_undefined(); } +moduleBaseFunction(moduleUiElementUpdateChildrenMethod) { + moduleBaseGetOrReturn(moduleuielementhandle_t, h, moduleUiElementGet); + uiElementUpdateChildren(&UI_ELEMENTS[h->id]); + return jerry_undefined(); +} + +moduleBaseFunction(moduleUiElementUpdateMethod) { + moduleBaseGetOrReturn(moduleuielementhandle_t, h, moduleUiElementGet); + uiElementUpdate(h->id); + return jerry_undefined(); +} + // Frees the registered JS instance for elementId and every descendant // (walked via the still-intact children[]/childCount), so the registry // never points at an about-to-be-disposed id. Must run BEFORE @@ -342,12 +391,19 @@ void moduleUiElementInit(void) { scriptProtoDefineFunc( &MODULE_UI_ELEMENT_PROTO, "render", moduleUiElementRenderMethod ); + scriptProtoDefineFunc( + &MODULE_UI_ELEMENT_PROTO, "updateChildren", moduleUiElementUpdateChildrenMethod + ); + scriptProtoDefineFunc( + &MODULE_UI_ELEMENT_PROTO, "update", moduleUiElementUpdateMethod + ); scriptProtoDefineFunc( &MODULE_UI_ELEMENT_PROTO, "dispose", moduleUiElementDisposeMethod ); UI_ELEMENT_CALLBACKS[UI_ELEMENT_TYPE_SCRIPTED] = (uilelementcallbacks_t){ - .render = moduleUiElementRenderScripted + .render = moduleUiElementRenderScripted, + .update = moduleUiElementUpdateScripted }; } diff --git a/src/dusk/script/module/ui/moduleuielement.h b/src/dusk/script/module/ui/moduleuielement.h index f200f9db..55415fd2 100644 --- a/src/dusk/script/module/ui/moduleuielement.h +++ b/src/dusk/script/module/ui/moduleuielement.h @@ -30,11 +30,12 @@ extern scriptproto_t MODULE_UI_ELEMENT_PROTO; * cache, just cheap to recompute), parent (read-only, the UIElement this * one was add()ed to, or undefined), childCount (read-only), add(child)/ * remove(child) (persistent parent/child links, up to - * UI_ELEMENT_CHILDREN_MAX per element), render(), renderChildren() - * (renders whatever was added, in add-order -- called automatically by - * render() unless a subclass overrides render(), in which case the - * override has full manual control and must call this itself if it - * still wants added children drawn), and dispose() (cascades to every + * UI_ELEMENT_CHILDREN_MAX per element), render()/update(), + * renderChildren()/updateChildren() (render/update whatever was added, + * in add-order -- called automatically by render()/update() unless a + * subclass overrides it, in which case the override has full manual + * control and must call the *Children() twin itself if it still wants + * added children rendered/updated), and dispose() (cascades to every * descendant). */ void moduleUiElementInit(void); diff --git a/src/dusk/ui/ui.c b/src/dusk/ui/ui.c index 8cbefc93..4c2ef060 100644 --- a/src/dusk/ui/ui.c +++ b/src/dusk/ui/ui.c @@ -35,10 +35,30 @@ void uiRootRemove(const uielementid_t elementId) { } } +bool_t uiOverlayRootAdd(const uielementid_t elementId) { + for(uint8_t i = 0; i < UI_OVERLAY_ROOT_MAX; i++) { + if(UI.overlayRoot[i] != UI_ELEMENT_ID_INVALID) continue; + UI.overlayRoot[i] = elementId; + return true; + } + return false; +} + +void uiOverlayRootRemove(const uielementid_t elementId) { + for(uint8_t i = 0; i < UI_OVERLAY_ROOT_MAX; i++) { + if(UI.overlayRoot[i] != elementId) continue; + UI.overlayRoot[i] = UI_ELEMENT_ID_INVALID; + return; + } +} + errorret_t uiInit(void) { memoryZero(UI_ELEMENTS, sizeof(UI_ELEMENTS)); memoryZero(&UI, sizeof(UI)); for(uint8_t i = 0; i < UI_ROOT_MAX; i++) UI.root[i] = UI_ELEMENT_ID_INVALID; + for(uint8_t i = 0; i < UI_OVERLAY_ROOT_MAX; i++) { + UI.overlayRoot[i] = UI_ELEMENT_ID_INVALID; + } consolePrint( "UI elements size: %zu bytes (%.2f KB), %zu bytes/element, " @@ -56,7 +76,15 @@ errorret_t uiUpdate(void) { if(TIME.dynamicUpdate) errorOk(); #endif - + for(uint8_t i = 0; i < UI_ROOT_MAX; i++) { + if(UI.root[i] == UI_ELEMENT_ID_INVALID) continue; + uiElementUpdate(UI.root[i]); + } + + for(uint8_t i = 0; i < UI_OVERLAY_ROOT_MAX; i++) { + if(UI.overlayRoot[i] == UI_ELEMENT_ID_INVALID) continue; + uiElementUpdate(UI.overlayRoot[i]); + } errorOk(); } @@ -99,6 +127,13 @@ errorret_t uiRender(void) { uiElementRender(UI.root[i]); } + // Always drawn after every UI.root[] element, regardless of add order + // between the two arrays -- debug overlays/screen fades stay on top. + for(uint8_t i = 0; i < UI_OVERLAY_ROOT_MAX; i++) { + if(UI.overlayRoot[i] == UI_ELEMENT_ID_INVALID) continue; + uiElementRender(UI.overlayRoot[i]); + } + errorChain(spriteBatchFlush()); errorOk(); } diff --git a/src/dusk/ui/ui.h b/src/dusk/ui/ui.h index a73df030..9dc34b3e 100644 --- a/src/dusk/ui/ui.h +++ b/src/dusk/ui/ui.h @@ -10,9 +10,16 @@ #include "uielement.h" #define UI_ROOT_MAX 32 +#define UI_OVERLAY_ROOT_MAX 8 typedef struct { uielementid_t root[UI_ROOT_MAX]; + + // Drawn/updated strictly after every UI.root[] element, regardless of + // add order between the two arrays -- for things that must always sit + // on top (debug overlays, screen fades/transitions), so they don't + // have to race normal UI content for root slots/order. + uielementid_t overlayRoot[UI_OVERLAY_ROOT_MAX]; } ui_t; extern ui_t UI; @@ -33,6 +40,23 @@ bool_t uiRootAdd(const uielementid_t elementId); */ void uiRootRemove(const uielementid_t elementId); +/** + * Adds an element to UI's overlay roots (drawn/updated after every + * UI.root[] element), in the first free slot. + * + * @param elementId The element id to add as an overlay root. + * @return True on success, false if UI.overlayRoot[] is already full. + */ +bool_t uiOverlayRootAdd(const uielementid_t elementId); + +/** + * Removes an element from UI's overlay roots, if present. No-op if + * elementId isn't currently an overlay root. + * + * @param elementId The element id to remove. + */ +void uiOverlayRootRemove(const uielementid_t elementId); + /** * Initializes the UI system. * diff --git a/src/dusk/ui/uielement.c b/src/dusk/ui/uielement.c index f8b609e9..71aaabc4 100644 --- a/src/dusk/ui/uielement.c +++ b/src/dusk/ui/uielement.c @@ -25,6 +25,8 @@ uielement_t UI_ELEMENTS[UI_ELEMENT_COUNT_MAX]; #define UI_ELEMENT_RENDER_DEPTH_MAX 16 static uint8_t UI_ELEMENT_RENDER_DEPTH = 0; +static void uiElementRenderPrepared(uielement_t *element); + uilelementcallbacks_t UI_ELEMENT_CALLBACKS[UI_ELEMENT_TYPE_COUNT] = { [UI_ELEMENT_TYPE_NULL] = {0}, @@ -80,6 +82,14 @@ void uiElementUpdate(const uielementid_t elementId) { } } +void uiElementUpdateChildren(uielement_t *element) { + assertNotNull(element, "Element is null"); + + for(uint8_t i = 0; i < element->childCount; i++) { + uiElementUpdate(element->children[i]); + } +} + void uiElementRender(const uielementid_t elementId) { assertTrue(elementId < UI_ELEMENT_COUNT_MAX, "Invalid ID"); @@ -87,7 +97,16 @@ void uiElementRender(const uielementid_t elementId) { assertTrue(element->type != UI_ELEMENT_TYPE_NULL, "Element is null type"); uiElementUpdateWorld(element); + uiElementRenderPrepared(element); +} +// Shared depth-guarded dispatch to the type's render callback, once +// worldX/worldY are already known-fresh. uiElementRender() gets there via +// a full uiElementUpdateWorld() walk (parent freshness unknown); child +// elements get there via uiElementRenderChildren(), which derives world +// position in O(1) from the parent it was just called on -- avoiding a +// second walk back up the same chain that's already mid-render. +static void uiElementRenderPrepared(uielement_t *element) { assertTrue( UI_ELEMENT_RENDER_DEPTH < UI_ELEMENT_RENDER_DEPTH_MAX, "UI render depth exceeded -- elements nested too deep" @@ -120,7 +139,10 @@ void uiElementRenderChildren(uielement_t *element) { assertNotNull(element, "Element is null"); for(uint8_t i = 0; i < element->childCount; i++) { - uiElementRender(element->children[i]); + uielement_t *child = &UI_ELEMENTS[element->children[i]]; + child->worldX = child->x + element->worldX; + child->worldY = child->y + element->worldY; + uiElementRenderPrepared(child); } } @@ -201,6 +223,7 @@ void uiElementDispose(const uielementid_t elementId) { uiElementSetParent(elementId, UI_ELEMENT_ID_INVALID); uiRootRemove(elementId); + uiOverlayRootRemove(elementId); for(uint8_t i = 0; i < element->childCount; i++) { UI_ELEMENTS[element->children[i]].parent = UI_ELEMENT_ID_INVALID; diff --git a/src/dusk/ui/uielement.h b/src/dusk/ui/uielement.h index 51c71e6b..3ac9f6a8 100644 --- a/src/dusk/ui/uielement.h +++ b/src/dusk/ui/uielement.h @@ -106,6 +106,17 @@ void uiElementUpdateWorld(uielement_t *element); */ void uiElementRenderChildren(uielement_t *element); +/** + * Updates every child of element. Called unconditionally by native leaf + * update callbacks (uiLabelUpdate, uiRectangleUpdate) since they have no + * override concept; called by UI_ELEMENT_TYPE_SCRIPTED's update callback + * only when the instance did NOT override update() -- mirrors + * uiElementRenderChildren/render() exactly. + * + * @param element The UI element whose children to update. + */ +void uiElementUpdateChildren(uielement_t *element); + /** * Sets the persistent parent of a UI element, detaching it from any * current parent first. Pass UI_ELEMENT_ID_INVALID to detach only. diff --git a/src/dusk/ui/widget/uilabel.c b/src/dusk/ui/widget/uilabel.c index ad3f627b..bc2ee8ab 100644 --- a/src/dusk/ui/widget/uilabel.c +++ b/src/dusk/ui/widget/uilabel.c @@ -36,8 +36,18 @@ void uiLabelSetText(const uielementid_t elementId, const char_t *text) { ); } +void uiLabelSetColor(const uielementid_t elementId, const color_t color) { + assertTrue(elementId < UI_ELEMENT_COUNT_MAX, "Invalid ID"); + + uielement_t *element = &UI_ELEMENTS[elementId]; + assertTrue(element->type == UI_ELEMENT_TYPE_LABEL, "Element is not a label"); + + element->label.color = color; +} + void uiLabelUpdate(uielement_t *element) { assertNotNull(element, "Element cannot be NULL"); + uiElementUpdateChildren(element); } void uiLabelRender(uielement_t *element) { diff --git a/src/dusk/ui/widget/uilabel.h b/src/dusk/ui/widget/uilabel.h index 41bf766e..029bc225 100644 --- a/src/dusk/ui/widget/uilabel.h +++ b/src/dusk/ui/widget/uilabel.h @@ -40,6 +40,14 @@ void uiLabelInit(uielement_t *element); */ void uiLabelSetText(const uielementid_t elementId, const char_t *text); +/** + * Sets the label's text color. + * + * @param elementId The ID of the UI element to update as a label. + * @param color The new text color. + */ +void uiLabelSetColor(const uielementid_t elementId, const color_t color); + /** * Updates a UI label element. * diff --git a/src/dusk/ui/widget/uirectangle.c b/src/dusk/ui/widget/uirectangle.c index 6ab20c4a..c06ad227 100644 --- a/src/dusk/ui/widget/uirectangle.c +++ b/src/dusk/ui/widget/uirectangle.c @@ -50,6 +50,7 @@ void uiRectangleSetColor(const uielementid_t elementId, const color_t color) { void uiRectangleUpdate(uielement_t *element) { assertNotNull(element, "Element cannot be NULL"); + uiElementUpdateChildren(element); } void uiRectangleRender(uielement_t *element) { diff --git a/test/script/CMakeLists.txt b/test/script/CMakeLists.txt index 7d867a77..98cbcec2 100644 --- a/test/script/CMakeLists.txt +++ b/test/script/CMakeLists.txt @@ -13,6 +13,14 @@ dusktest(test_moduleconsole.c) dusktest(test_moduleplatform.c) +dusktest(test_moduletime.c) + +dusktest(test_modulescreen.c) + +dusktest(test_modulelocale.c) + +dusktest(test_modulesave.c) + dusktest(test_moduleinput.c) dusktest(test_scriptinput.c) diff --git a/test/script/test_moduleconsole.c b/test/script/test_moduleconsole.c index 323adbe4..a9bd988e 100644 --- a/test/script/test_moduleconsole.c +++ b/test/script/test_moduleconsole.c @@ -8,6 +8,7 @@ #include "dusktest.h" #include "console/console.h" #include "script/scriptmanager.h" +#include "script/module/modulebase.h" #include "util/memory.h" static int console_setup(void **state) { @@ -53,6 +54,59 @@ static void test_console_print_no_args_prints_empty_line(void **state) { assert_int_equal(memoryGetAllocatedCount(), 0); } +static void test_console_line_count_is_history_max(void **state) { + jerry_value_t result; + errorret_t ret = scriptManagerExec("Console.lineCount;", &result); + assert_true(errorIsOk(ret)); + assert_int_equal((int32_t)jerry_value_as_number(result), CONSOLE_HISTORY_MAX); + jerry_value_free(result); +} + +static void test_console_get_line_reads_history(void **state) { + jerry_value_t result; + errorret_t ret = scriptManagerExec( + "Console.print('a line'); Console.getLine(Console.lineCount - 1);", + &result + ); + assert_true(errorIsOk(ret)); + + char_t buf[32]; + moduleBaseToString(result, buf, sizeof(buf)); + assert_string_equal(buf, "a line"); + jerry_value_free(result); +} + +static void test_console_get_line_out_of_range_throws(void **state) { + errorret_t ret = scriptManagerExec("Console.getLine(999);", NULL); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); +} + +static void test_console_visible_defaults_false(void **state) { + jerry_value_t result; + errorret_t ret = scriptManagerExec("Console.visible;", &result); + assert_true(errorIsOk(ret)); + assert_false(jerry_value_is_true(result)); + jerry_value_free(result); +} + +static void test_console_consume_dirty_clears_flag(void **state) { + CONSOLE.dirty = true; + + jerry_value_t result; + errorret_t ret = scriptManagerExec("Console.consumeDirty();", &result); + assert_true(errorIsOk(ret)); + assert_true(jerry_value_is_true(result)); + jerry_value_free(result); + + assert_false(CONSOLE.dirty); + + ret = scriptManagerExec("Console.consumeDirty();", &result); + assert_true(errorIsOk(ret)); + assert_false(jerry_value_is_true(result)); + jerry_value_free(result); +} + int main(void) { assertInit(); const struct CMUnitTest tests[] = { @@ -64,6 +118,22 @@ int main(void) { test_console_print_no_args_prints_empty_line, console_setup, console_teardown ), + cmocka_unit_test_setup_teardown( + test_console_line_count_is_history_max, console_setup, console_teardown + ), + cmocka_unit_test_setup_teardown( + test_console_get_line_reads_history, console_setup, console_teardown + ), + cmocka_unit_test_setup_teardown( + test_console_get_line_out_of_range_throws, + console_setup, console_teardown + ), + cmocka_unit_test_setup_teardown( + test_console_visible_defaults_false, console_setup, console_teardown + ), + cmocka_unit_test_setup_teardown( + test_console_consume_dirty_clears_flag, console_setup, console_teardown + ), }; return cmocka_run_group_tests(tests, NULL, NULL); } diff --git a/test/script/test_moduleinput.c b/test/script/test_moduleinput.c index c7b575dc..6c6ebd90 100644 --- a/test/script/test_moduleinput.c +++ b/test/script/test_moduleinput.c @@ -122,12 +122,29 @@ static void test_input_query_functions_default_to_zero(void **state) { jerry_value_free(result); } +static void test_input_deadzone_readback(void **state) { + jerry_value_t result; + errorret_t ret = scriptManagerExec("Input.deadzone = 0.25;", &result); + assert_true(errorIsOk(ret)); + jerry_value_free(result); + + assert_float_equal(INPUT.deadzone, 0.25f, 0.0001f); + + ret = scriptManagerExec("Input.deadzone;", &result); + assert_true(errorIsOk(ret)); + assert_float_equal((float_t)jerry_value_as_number(result), 0.25f, 0.0001f); + jerry_value_free(result); +} + int main(void) { assertInit(); const struct CMUnitTest tests[] = { cmocka_unit_test_setup_teardown( test_inputbind_exposes_action_ordinals, input_setup, input_teardown ), + cmocka_unit_test_setup_teardown( + test_input_deadzone_readback, input_setup, input_teardown + ), cmocka_unit_test_setup_teardown( test_inputbutton_exposes_platform_button_names, input_setup, input_teardown diff --git a/test/script/test_modulelocale.c b/test/script/test_modulelocale.c new file mode 100644 index 00000000..3adda2d5 --- /dev/null +++ b/test/script/test_modulelocale.c @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "script/scriptmanager.h" +#include "locale/localemanager.h" + +static int locale_setup(void **state) { + errorret_t ret = scriptManagerInit(); + if(errorIsNotOk(ret)) { errorCatch(ret); return -1; } + return 0; +} + +static int locale_teardown(void **state) { + errorret_t ret = scriptManagerDispose(); + if(errorIsNotOk(ret)) errorCatch(ret); + return 0; +} + +// Only the binding shape is covered here -- localeManagerInit() was never +// called (LOCALE.locale stays NULL, as a fresh process leaves it), and an +// actual successful Locale.set() requires the real asset pipeline (a +// mounted locale/*.po file) which the main Dusk binary's headless run +// exercises instead; a plain module test isn't the place to stand up the +// full asset system just for this. + +static void test_locale_current_defaults_to_empty_string(void **state) { + jerry_value_t result; + errorret_t ret = scriptManagerExec("Locale.current;", &result); + assert_true(errorIsOk(ret)); + + char_t buf[8]; + jerry_string_to_buffer( + result, JERRY_ENCODING_UTF8, (jerry_char_t *)buf, sizeof(buf) - 1 + ); + jerry_value_free(result); +} + +static void test_locale_set_unknown_name_throws(void **state) { + errorret_t ret = scriptManagerExec("Locale.set('xx-XX');", NULL); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); +} + +static void test_locale_set_requires_a_string(void **state) { + errorret_t ret = scriptManagerExec("Locale.set(42);", NULL); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); +} + +int main(void) { + assertInit(); + const struct CMUnitTest tests[] = { + cmocka_unit_test_setup_teardown( + test_locale_current_defaults_to_empty_string, + locale_setup, locale_teardown + ), + cmocka_unit_test_setup_teardown( + test_locale_set_unknown_name_throws, locale_setup, locale_teardown + ), + cmocka_unit_test_setup_teardown( + test_locale_set_requires_a_string, locale_setup, locale_teardown + ), + }; + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/script/test_modulesave.c b/test/script/test_modulesave.c new file mode 100644 index 00000000..f34d7867 --- /dev/null +++ b/test/script/test_modulesave.c @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "script/scriptmanager.h" +#include "save/save.h" +#include "save/savesettings.h" +#include "input/input.h" + +static int save_setup(void **state) { + // Writes under ./saves relative to this test binary's own working + // directory (SAVE_LINUX_PATH's default) -- a build artifact, not real + // user save data. + errorret_t ret = saveInit(); + if(errorIsNotOk(ret)) { errorCatch(ret); return -1; } + + ret = scriptManagerInit(); + if(errorIsNotOk(ret)) { errorCatch(ret); return -1; } + + return 0; +} + +static int save_teardown(void **state) { + errorret_t ret = scriptManagerDispose(); + if(errorIsNotOk(ret)) errorCatch(ret); + + ret = saveDispose(); + if(errorIsNotOk(ret)) errorCatch(ret); + + return 0; +} + +static void test_save_write_persists_settings(void **state) { + INPUT.deadzone = 0.42f; + + jerry_value_t result; + errorret_t ret = scriptManagerExec("Save.write();", &result); + assert_true(errorIsOk(ret)); + jerry_value_free(result); + + INPUT.deadzone = 0.0f; + ret = saveSettingsLoad(); + assert_true(errorIsOk(ret)); + assert_float_equal(INPUT.deadzone, 0.42f, 0.0001f); +} + +int main(void) { + assertInit(); + const struct CMUnitTest tests[] = { + cmocka_unit_test_setup_teardown( + test_save_write_persists_settings, save_setup, save_teardown + ), + }; + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/script/test_modulescreen.c b/test/script/test_modulescreen.c new file mode 100644 index 00000000..c4939032 --- /dev/null +++ b/test/script/test_modulescreen.c @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "script/scriptmanager.h" +#include "display/screen/screen.h" + +static int screen_setup(void **state) { + errorret_t ret = scriptManagerInit(); + if(errorIsNotOk(ret)) { errorCatch(ret); return -1; } + return 0; +} + +static int screen_teardown(void **state) { + errorret_t ret = scriptManagerDispose(); + if(errorIsNotOk(ret)) errorCatch(ret); + return 0; +} + +static void test_screen_dimensions_readback(void **state) { + SCREEN.width = 640; + SCREEN.height = 480; + SCREEN.scanX = 10; + SCREEN.scanY = 20; + SCREEN.scanWidth = 600; + SCREEN.scanHeight = 440; + + jerry_value_t result; + + errorret_t ret = scriptManagerExec("Screen.width;", &result); + assert_true(errorIsOk(ret)); + assert_int_equal((int32_t)jerry_value_as_number(result), 640); + jerry_value_free(result); + + ret = scriptManagerExec("Screen.height;", &result); + assert_true(errorIsOk(ret)); + assert_int_equal((int32_t)jerry_value_as_number(result), 480); + jerry_value_free(result); + + ret = scriptManagerExec("Screen.scanX;", &result); + assert_true(errorIsOk(ret)); + assert_int_equal((int32_t)jerry_value_as_number(result), 10); + jerry_value_free(result); + + ret = scriptManagerExec("Screen.scanY;", &result); + assert_true(errorIsOk(ret)); + assert_int_equal((int32_t)jerry_value_as_number(result), 20); + jerry_value_free(result); + + ret = scriptManagerExec("Screen.scanWidth;", &result); + assert_true(errorIsOk(ret)); + assert_int_equal((int32_t)jerry_value_as_number(result), 600); + jerry_value_free(result); + + ret = scriptManagerExec("Screen.scanHeight;", &result); + assert_true(errorIsOk(ret)); + assert_int_equal((int32_t)jerry_value_as_number(result), 440); + jerry_value_free(result); +} + +int main(void) { + assertInit(); + const struct CMUnitTest tests[] = { + cmocka_unit_test_setup_teardown( + test_screen_dimensions_readback, screen_setup, screen_teardown + ), + }; + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/script/test_moduletime.c b/test/script/test_moduletime.c new file mode 100644 index 00000000..045e2fce --- /dev/null +++ b/test/script/test_moduletime.c @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "script/scriptmanager.h" +#include "time/time.h" + +static int time_setup(void **state) { + errorret_t ret = scriptManagerInit(); + if(errorIsNotOk(ret)) { errorCatch(ret); return -1; } + return 0; +} + +static int time_teardown(void **state) { + errorret_t ret = scriptManagerDispose(); + if(errorIsNotOk(ret)) errorCatch(ret); + return 0; +} + +static void test_time_delta_and_time_readback(void **state) { + TIME.delta = 0.5f; + TIME.time = 12.0f; + + jerry_value_t result; + errorret_t ret = scriptManagerExec("Time.delta;", &result); + assert_true(errorIsOk(ret)); + assert_float_equal((float_t)jerry_value_as_number(result), 0.5f, 0.0001f); + jerry_value_free(result); + + ret = scriptManagerExec("Time.time;", &result); + assert_true(errorIsOk(ret)); + assert_float_equal((float_t)jerry_value_as_number(result), 12.0f, 0.0001f); + jerry_value_free(result); +} + +static void test_time_render_delta_readback(void **state) { + // Under DUSK_TIME_DYNAMIC, renderDelta reads dynamicDelta (real + // per-render-frame time); otherwise it falls back to the fixed delta. + // Set both so this test passes either way this binary was built. + TIME.delta = 0.25f; + #ifdef DUSK_TIME_DYNAMIC + TIME.dynamicDelta = 0.75f; + #endif + + jerry_value_t result; + errorret_t ret = scriptManagerExec("Time.renderDelta;", &result); + assert_true(errorIsOk(ret)); + #ifdef DUSK_TIME_DYNAMIC + assert_float_equal((float_t)jerry_value_as_number(result), 0.75f, 0.0001f); + #else + assert_float_equal((float_t)jerry_value_as_number(result), 0.25f, 0.0001f); + #endif + jerry_value_free(result); +} + +int main(void) { + assertInit(); + const struct CMUnitTest tests[] = { + cmocka_unit_test_setup_teardown( + test_time_delta_and_time_readback, time_setup, time_teardown + ), + cmocka_unit_test_setup_teardown( + test_time_render_delta_readback, time_setup, time_teardown + ), + }; + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/script/test_moduleui.c b/test/script/test_moduleui.c index 4233382b..5b6e1cc4 100644 --- a/test/script/test_moduleui.c +++ b/test/script/test_moduleui.c @@ -17,6 +17,9 @@ static int ui_setup(void **state) { memoryZero(UI_ELEMENTS, sizeof(UI_ELEMENTS)); memoryZero(&UI, sizeof(UI)); for(uint8_t i = 0; i < UI_ROOT_MAX; i++) UI.root[i] = UI_ELEMENT_ID_INVALID; + for(uint8_t i = 0; i < UI_OVERLAY_ROOT_MAX; i++) { + UI.overlayRoot[i] = UI_ELEMENT_ID_INVALID; + } errorret_t ret = scriptManagerInit(); if(errorIsNotOk(ret)) { errorCatch(ret); return -1; } @@ -32,6 +35,17 @@ static int ui_teardown(void **state) { return 0; } +static void exec(const char_t *script) { + jerry_value_t result; + errorret_t ret = scriptManagerExec(script, &result); + if(errorIsNotOk(ret)) { + errorCatch(errorPrint(ret)); + fail_msg("Script execution failed (see printed error above)"); + } + assert_true(errorIsOk(ret)); + jerry_value_free(result); +} + static void test_ui_add_requires_an_argument(void **state) { errorret_t ret = scriptManagerExec("UI.add();", NULL); assert_true(errorIsNotOk(ret)); @@ -65,6 +79,64 @@ static void test_ui_remove_of_untracked_element_is_a_noop(void **state) { jerry_value_free(result); } +static void test_ui_add_overlay_and_remove_overlay(void **state) { + assert_true(UI.overlayRoot[0] == UI_ELEMENT_ID_INVALID); + + exec( + "globalThis.el = new UIElement();" + "el.x = 3;" + "UI.addOverlay(el);" + ); + + assert_true(UI.overlayRoot[0] != UI_ELEMENT_ID_INVALID); + assert_true(UI_ELEMENTS[UI.overlayRoot[0]].type == UI_ELEMENT_TYPE_SCRIPTED); + assert_float_equal(UI_ELEMENTS[UI.overlayRoot[0]].x, 3.0f, 0.001f); + + exec("UI.removeOverlay(el);"); + + assert_true(UI.overlayRoot[0] == UI_ELEMENT_ID_INVALID); +} + +static void test_ui_add_overlay_detaches_from_parent(void **state) { + exec( + "globalThis.parent = new UIElement();" + "globalThis.child = new UIElement();" + "parent.add(child);" + "if(parent.childCount !== 1) throw new Error('expected 1 child');" + "UI.addOverlay(child);" + "if(parent.childCount !== 0) {" + " throw new Error('expected 0 children after UI.addOverlay');" + "}" + "if(child.parent !== undefined) throw new Error('child.parent should be undefined');" + ); +} + +static void test_ui_dispose_removes_overlay_root(void **state) { + exec( + "globalThis.el = new UIElement();" + "UI.addOverlay(el);" + ); + assert_true(UI.overlayRoot[0] != UI_ELEMENT_ID_INVALID); + + exec("el.dispose();"); + assert_true(UI.overlayRoot[0] == UI_ELEMENT_ID_INVALID); +} + +static void test_ui_overlay_root_is_independent_of_root(void **state) { + // A normal root and an overlay root are two separate arrays -- adding + // to one must never touch the other. + exec( + "globalThis.a = new UIElement();" + "globalThis.b = new UIElement();" + "UI.add(a);" + "UI.addOverlay(b);" + ); + + assert_true(UI.root[0] != UI_ELEMENT_ID_INVALID); + assert_true(UI.overlayRoot[0] != UI_ELEMENT_ID_INVALID); + assert_true(UI.root[0] != UI.overlayRoot[0]); +} + int main(void) { assertInit(); const struct CMUnitTest tests[] = { @@ -80,6 +152,18 @@ int main(void) { cmocka_unit_test_setup_teardown( test_ui_remove_of_untracked_element_is_a_noop, ui_setup, ui_teardown ), + cmocka_unit_test_setup_teardown( + test_ui_add_overlay_and_remove_overlay, ui_setup, ui_teardown + ), + cmocka_unit_test_setup_teardown( + test_ui_add_overlay_detaches_from_parent, ui_setup, ui_teardown + ), + cmocka_unit_test_setup_teardown( + test_ui_dispose_removes_overlay_root, ui_setup, ui_teardown + ), + cmocka_unit_test_setup_teardown( + test_ui_overlay_root_is_independent_of_root, ui_setup, ui_teardown + ), }; return cmocka_run_group_tests(tests, NULL, NULL); } diff --git a/test/script/test_moduleuielement.c b/test/script/test_moduleuielement.c index 88ea0b08..365bc65b 100644 --- a/test/script/test_moduleuielement.c +++ b/test/script/test_moduleuielement.c @@ -173,6 +173,96 @@ static void test_uielement_render_children_explicit_opt_in(void **state) { ); } +static void test_uielement_update_no_override_is_noop_when_childless( + void **state +) { + // No override, no children -- update() should just do nothing, safely. + exec("new UIElement().update();"); +} + +static void test_uielement_update_dispatches_to_js_override(void **state) { + exec( + "class Foo extends UIElement {" + " update() { this.updated = true; }" + "}" + "var foo = new Foo();" + "foo.update();" + "if(foo.updated !== true) throw new Error('update() override not called');" + ); +} + +static void test_uielement_no_override_auto_updates_children(void **state) { + exec( + "class Child extends UIElement {" + " update() { this.updated = true; }" + "}" + "var parent = new UIElement();" + "var child = new Child();" + "parent.add(child);" + "parent.update();" + "if(child.updated !== true) throw new Error('child was not auto-updated');" + ); +} + +static void test_uielement_override_does_not_auto_update_children( + void **state +) { + exec( + "class Child extends UIElement {" + " update() { this.updated = true; }" + "}" + "class Parent extends UIElement {" + " update() { this.overrideCalled = true; }" + "}" + "var parent = new Parent();" + "var child = new Child();" + "parent.add(child);" + "parent.update();" + "if(parent.overrideCalled !== true) throw new Error('override not called');" + "if(child.updated === true) {" + " throw new Error('child should not auto-update under an override');" + "}" + ); +} + +static void test_uielement_update_children_explicit_opt_in(void **state) { + exec( + "class Child extends UIElement {" + " update() { this.updated = true; }" + "}" + "class Parent extends UIElement {" + " update() { this.updateChildren(); }" + "}" + "var parent = new Parent();" + "var child = new Child();" + "parent.add(child);" + "parent.update();" + "if(child.updated !== true) throw new Error('updateChildren() did not update child');" + ); +} + +static void test_ui_update_reaches_root_and_children(void **state) { + exec( + "globalThis.parent = new UIElement();" + "globalThis.child = new UIElement();" + "class Grandchild extends UIElement {" + " update() { this.updated = true; }" + "}" + "globalThis.grandchild = new Grandchild();" + "parent.add(child);" + "child.add(grandchild);" + "UI.add(parent);" + ); + + errorCatch(errorPrint(uiUpdate())); + + exec( + "if(grandchild.updated !== true) {" + " throw new Error('uiUpdate() did not reach a rooted grandchild');" + "}" + ); +} + static void test_uielement_add_self_throws(void **state) { execExpectError("var el = new UIElement(); el.add(el);"); } @@ -252,6 +342,17 @@ static void test_label_text_default_and_set(void **state) { ); } +static void test_label_color_readback(void **state) { + exec( + "var label = new Label();" + "label.color = { r: 10, g: 20, b: 30, a: 40 };" + "if(label.color.r !== 10 || label.color.g !== 20 ||" + " label.color.b !== 30 || label.color.a !== 40) {" + " throw new Error('color readback wrong');" + "}" + ); +} + static void test_rectangle_size_and_color(void **state) { exec( "var rect = new Rectangle();" @@ -332,6 +433,30 @@ int main(void) { test_uielement_render_children_explicit_opt_in, uielement_setup, uielement_teardown ), + cmocka_unit_test_setup_teardown( + test_uielement_update_no_override_is_noop_when_childless, + uielement_setup, uielement_teardown + ), + cmocka_unit_test_setup_teardown( + test_uielement_update_dispatches_to_js_override, + uielement_setup, uielement_teardown + ), + cmocka_unit_test_setup_teardown( + test_uielement_no_override_auto_updates_children, + uielement_setup, uielement_teardown + ), + cmocka_unit_test_setup_teardown( + test_uielement_override_does_not_auto_update_children, + uielement_setup, uielement_teardown + ), + cmocka_unit_test_setup_teardown( + test_uielement_update_children_explicit_opt_in, + uielement_setup, uielement_teardown + ), + cmocka_unit_test_setup_teardown( + test_ui_update_reaches_root_and_children, + uielement_setup, uielement_teardown + ), cmocka_unit_test_setup_teardown( test_uielement_add_self_throws, uielement_setup, uielement_teardown ), @@ -355,6 +480,9 @@ int main(void) { cmocka_unit_test_setup_teardown( test_label_text_default_and_set, uielement_setup, uielement_teardown ), + cmocka_unit_test_setup_teardown( + test_label_color_readback, uielement_setup, uielement_teardown + ), cmocka_unit_test_setup_teardown( test_rectangle_size_and_color, uielement_setup, uielement_teardown ), diff --git a/types/ui/label.d.ts b/types/ui/label.d.ts index 24513a34..64b4ec2b 100644 --- a/types/ui/label.d.ts +++ b/types/ui/label.d.ts @@ -14,4 +14,6 @@ declare class Label extends UIElement { /** Display text. Rebuilds the label's glyph cache on assignment. */ text: string; + /** Text color, 0-255 per channel. */ + color: { r: number; g: number; b: number; a: number }; } diff --git a/types/ui/uielement.d.ts b/types/ui/uielement.d.ts index 692c43ba..5b26ce27 100644 --- a/types/ui/uielement.d.ts +++ b/types/ui/uielement.d.ts @@ -99,6 +99,21 @@ declare class UIElement { * overridden render(). */ renderChildren(): void; + /** + * Called once per tick. The base implementation calls updateChildren() + * and nothing else. Override it for per-tick logic (e.g. polling + * input) -- an override does NOT get its children auto-updated; call + * updateChildren() yourself if you still want them updated. Not called + * automatically by the engine unless this element is a render root + * (added via UI.add()) or a descendant of one. + */ + update(): void; + + /** Updates every added child, in add-order. Called automatically by + * the base update() -- only needed explicitly from inside an + * overridden update(). */ + updateChildren(): void; + /** Releases this element's pool slot and cascades to every descendant * (each disposed the same way, recursively). Also removes it from * UI's render roots if it was added there. Safe to call more than