Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5184064a26 | |||
| 6a43363539 | |||
| b9195fbbad | |||
| f715ad2176 |
@@ -33,74 +33,3 @@ jobs:
|
|||||||
libssl-dev
|
libssl-dev
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: ./scripts/test-linux.sh
|
run: ./scripts/test-linux.sh
|
||||||
|
|
||||||
# Emulator smoke tests: boot the built disc/EBOOT for a fixed window and
|
|
||||||
# confirm the emulator doesn't crash. Not yet verified against a real
|
|
||||||
# runner (no CI run has exercised these) -- continue-on-error so a
|
|
||||||
# flaky/broken emulator step doesn't block the required Linux test job.
|
|
||||||
run-tests-gamecube-dolphin:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
continue-on-error: true
|
|
||||||
container:
|
|
||||||
image: ghcr.io/extremscorner/libogc2:latest
|
|
||||||
steps:
|
|
||||||
- name: Install Node.js
|
|
||||||
run: apt-get update && apt-get install -y nodejs
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Install additional dependencies
|
|
||||||
run: |
|
|
||||||
apt-get install -y \
|
|
||||||
python3-pip python3-polib python3-pil \
|
|
||||||
python3-dotenv python3-pyqt5 python3-opengl xorriso \
|
|
||||||
dolphin-emu xvfb
|
|
||||||
dkp-pacman -Syu --noconfirm
|
|
||||||
dkp-pacman -S --needed --noconfirm \
|
|
||||||
gamecube-sdl2 ppc-liblzma ppc-libzip \
|
|
||||||
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
|
|
||||||
- name: Build GameCube ISO and boot it in Dolphin
|
|
||||||
run: ./scripts/test-gamecube-dolphin.sh
|
|
||||||
|
|
||||||
run-tests-wii-dolphin:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
continue-on-error: true
|
|
||||||
container:
|
|
||||||
image: ghcr.io/extremscorner/libogc2:latest
|
|
||||||
steps:
|
|
||||||
- name: Install Node.js
|
|
||||||
run: apt-get update && apt-get install -y nodejs
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Install additional dependencies
|
|
||||||
run: |
|
|
||||||
apt-get install -y \
|
|
||||||
python3-pip python3-polib python3-pil \
|
|
||||||
python3-dotenv python3-pyqt5 python3-opengl xorriso \
|
|
||||||
dolphin-emu xvfb
|
|
||||||
dkp-pacman -Syu --noconfirm
|
|
||||||
dkp-pacman -S --needed --noconfirm \
|
|
||||||
gamecube-sdl2 ppc-liblzma ppc-libzip \
|
|
||||||
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
|
|
||||||
- name: Build Wii ISO and boot it in Dolphin
|
|
||||||
run: ./scripts/test-wii-dolphin.sh
|
|
||||||
|
|
||||||
run-tests-psp-ppsspp:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
continue-on-error: true
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Setup pspdev
|
|
||||||
uses: ./.github/actions/setup-pspdev
|
|
||||||
- name: Install PPSSPPHeadless build dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y git cmake ninja-build libsdl2-dev zlib1g-dev
|
|
||||||
- name: Build PPSSPPHeadless
|
|
||||||
run: |
|
|
||||||
git clone --recursive --depth 1 https://github.com/hrydgard/ppsspp.git /tmp/ppsspp
|
|
||||||
cmake -S /tmp/ppsspp -B /tmp/ppsspp/build -DCMAKE_BUILD_TYPE=Release
|
|
||||||
cmake --build /tmp/ppsspp/build --target PPSSPPHeadless -- -j$(nproc)
|
|
||||||
echo "PPSSPP_HEADLESS_BIN=/tmp/ppsspp/build/PPSSPPHeadless" >> "$GITHUB_ENV"
|
|
||||||
- name: Build PSP EBOOT and boot it in PPSSPPHeadless
|
|
||||||
run: ./scripts/test-psp-ppsspp.sh
|
|
||||||
|
|||||||
+1
-12
@@ -13,7 +13,6 @@ cmake_policy(SET CMP0079 NEW)
|
|||||||
# set(FETCHCONTENT_UPDATES_DISCONNECTED ON)
|
# set(FETCHCONTENT_UPDATES_DISCONNECTED ON)
|
||||||
|
|
||||||
option(DUSK_BUILD_TESTS "Enable tests" OFF)
|
option(DUSK_BUILD_TESTS "Enable tests" OFF)
|
||||||
option(DUSK_NETWORKING "Enable networking support" OFF)
|
|
||||||
|
|
||||||
set(DUSK_GAME_NAME "Dusk" CACHE STRING "Game display name")
|
set(DUSK_GAME_NAME "Dusk" CACHE STRING "Game display name")
|
||||||
set(DUSK_GAME_AUTHOR "YourWishes" CACHE STRING "Game author / coder")
|
set(DUSK_GAME_AUTHOR "YourWishes" CACHE STRING "Game author / coder")
|
||||||
@@ -91,12 +90,6 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME}
|
|||||||
DUSK_VERSION="${DUSK_VERSION}"
|
DUSK_VERSION="${DUSK_VERSION}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if(DUSK_NETWORKING)
|
|
||||||
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
|
||||||
DUSK_NETWORKING
|
|
||||||
)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
# Toolchains
|
# Toolchains
|
||||||
include(cmake/targets/${DUSK_TARGET_SYSTEM}.cmake)
|
include(cmake/targets/${DUSK_TARGET_SYSTEM}.cmake)
|
||||||
|
|
||||||
@@ -123,11 +116,7 @@ if(DUSK_BUILD_TESTS)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Build assets
|
# Build assets
|
||||||
# Deliberately not CONFIGURE_DEPENDS: that reruns the full CMake configure
|
file(GLOB_RECURSE DUSK_ASSET_FILES CONFIGURE_DEPENDS "${DUSK_ASSETS_DIR}/*")
|
||||||
# step (which invalidates generated headers and forces a huge rebuild) on
|
|
||||||
# every single asset edit. Re-run cmake manually when assets are added or
|
|
||||||
# removed.
|
|
||||||
file(GLOB_RECURSE DUSK_ASSET_FILES "${DUSK_ASSETS_DIR}/*")
|
|
||||||
add_custom_command(
|
add_custom_command(
|
||||||
OUTPUT "${DUSK_ASSETS_ZIP}"
|
OUTPUT "${DUSK_ASSETS_ZIP}"
|
||||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${DUSK_ASSETS_DIR}"
|
COMMAND ${CMAKE_COMMAND} -E make_directory "${DUSK_ASSETS_DIR}"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# Dusk
|
# Documentation
|
||||||
RPG Game Project, small and able to run on a PSP.
|
- [Scripting](docs/SCRIPTING.md) — writing gameplay logic in JavaScript.
|
||||||
|
- [UI](docs/UI.md) — building buttons/menus/etc from engine/game C code.
|
||||||
|
|
||||||
# Building
|
# Building
|
||||||
Each build target has different requirements. You can take a look at the git
|
Each build target has different requirements. You can take a look at the git
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
# Copyright (c) 2026 Dominic Masters
|
|
||||||
#
|
|
||||||
# This software is released under the MIT License.
|
|
||||||
# https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
add_subdirectory(debug)
|
|
||||||
add_subdirectory(frame)
|
|
||||||
add_subdirectory(focus)
|
|
||||||
add_subdirectory(overlay)
|
|
||||||
add_subdirectory(transition)
|
|
||||||
add_subdirectory(widget)
|
|
||||||
|
|
||||||
# Sources
|
|
||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
|
||||||
PUBLIC
|
|
||||||
ui.c
|
|
||||||
uielement.c
|
|
||||||
)
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uiconsole.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "console/console.h"
|
|
||||||
#include "display/screen/screen.h"
|
|
||||||
#include "display/text/text.h"
|
|
||||||
#include "display/spritebatch/spritebatch.h"
|
|
||||||
#include "display/shader/shaderunlit.h"
|
|
||||||
#include "display/mesh/mesh.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
mesh_t mesh;
|
|
||||||
bool_t built;
|
|
||||||
meshvertex_t vertices[UI_CONSOLE_CACHE_VERTEX_MAX];
|
|
||||||
int32_t vertexCount;
|
|
||||||
int32_t cachedScanX;
|
|
||||||
int32_t cachedScanY;
|
|
||||||
} uiconsolecache_t;
|
|
||||||
|
|
||||||
uiconsolecache_t UI_CONSOLE_CACHE;
|
|
||||||
|
|
||||||
errorret_t uiConsoleDraw(void) {
|
|
||||||
if(!CONSOLE.visible) errorOk();
|
|
||||||
|
|
||||||
if(
|
|
||||||
CONSOLE.dirty ||
|
|
||||||
UI_CONSOLE_CACHE.cachedScanX != SCREEN.scanX ||
|
|
||||||
UI_CONSOLE_CACHE.cachedScanY != SCREEN.scanY
|
|
||||||
) {
|
|
||||||
errorChain(uiConsoleRebuild());
|
|
||||||
}
|
|
||||||
|
|
||||||
if(UI_CONSOLE_CACHE.vertexCount == 0) errorOk();
|
|
||||||
|
|
||||||
shadermaterial_t material = {
|
|
||||||
.unlit = {
|
|
||||||
.color = COLOR_RED,
|
|
||||||
.texture = FONT_DEFAULT.texture
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
errorChain(shaderBind(&SHADER_UNLIT));
|
|
||||||
errorChain(shaderSetMaterial(&SHADER_UNLIT, &material));
|
|
||||||
return meshDraw(&UI_CONSOLE_CACHE.mesh, 0, UI_CONSOLE_CACHE.vertexCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiConsoleRebuild(void) {
|
|
||||||
int32_t spriteCount = 0;
|
|
||||||
for(uint32_t i = 0; i < CONSOLE_HISTORY_MAX; i++) {
|
|
||||||
char_t c;
|
|
||||||
int32_t j = 0;
|
|
||||||
while((c = CONSOLE.line[i][j++]) != '\0') {
|
|
||||||
if(c != ' ' && c != '\n') spriteCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
spriteCount <= UI_CONSOLE_CACHE_GLYPH_MAX,
|
|
||||||
"Console history exceeds fixed cache capacity"
|
|
||||||
);
|
|
||||||
|
|
||||||
const int32_t vertexCount = spriteCount * QUAD_VERTEX_COUNT;
|
|
||||||
UI_CONSOLE_CACHE.vertexCount = vertexCount;
|
|
||||||
|
|
||||||
if(vertexCount > 0) {
|
|
||||||
const float_t lineH = (float_t)FONT_DEFAULT.tileset->tileHeight;
|
|
||||||
const float_t charW = (float_t)FONT_DEFAULT.tileset->tileWidth;
|
|
||||||
|
|
||||||
int32_t index = 0;
|
|
||||||
int32_t row = 0;
|
|
||||||
for(uint32_t i = 0; i < CONSOLE_HISTORY_MAX; i++) {
|
|
||||||
float_t posX = (float_t)SCREEN.scanX;
|
|
||||||
float_t posY = (float_t)SCREEN.scanY + lineH * (float_t)row;
|
|
||||||
|
|
||||||
char_t c;
|
|
||||||
int32_t j = 0;
|
|
||||||
while((c = CONSOLE.line[i][j++]) != '\0') {
|
|
||||||
if(c == '\n') {
|
|
||||||
posX = (float_t)SCREEN.scanX;
|
|
||||||
posY += lineH;
|
|
||||||
row++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(c == ' ') {
|
|
||||||
posX += charW;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const spritebatchsprite_t sprite = textGetSprite(
|
|
||||||
(vec2){ posX, posY }, c, &FONT_DEFAULT
|
|
||||||
);
|
|
||||||
spriteBatchBufferToMesh(
|
|
||||||
&sprite, 1,
|
|
||||||
&UI_CONSOLE_CACHE.vertices[index], QUAD_VERTEX_COUNT
|
|
||||||
);
|
|
||||||
|
|
||||||
index += QUAD_VERTEX_COUNT;
|
|
||||||
posX += charW;
|
|
||||||
}
|
|
||||||
|
|
||||||
row++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!UI_CONSOLE_CACHE.built) {
|
|
||||||
errorChain(meshInit(
|
|
||||||
&UI_CONSOLE_CACHE.mesh,
|
|
||||||
QUAD_PRIMITIVE_TYPE,
|
|
||||||
UI_CONSOLE_CACHE_VERTEX_MAX,
|
|
||||||
UI_CONSOLE_CACHE.vertices
|
|
||||||
));
|
|
||||||
UI_CONSOLE_CACHE.built = true;
|
|
||||||
} else {
|
|
||||||
errorChain(meshFlush(&UI_CONSOLE_CACHE.mesh, 0, vertexCount));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
UI_CONSOLE_CACHE.cachedScanX = SCREEN.scanX;
|
|
||||||
UI_CONSOLE_CACHE.cachedScanY = SCREEN.scanY;
|
|
||||||
CONSOLE.dirty = false;
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiConsoleDispose(void) {
|
|
||||||
if(UI_CONSOLE_CACHE.built) {
|
|
||||||
errorChain(meshDispose(&UI_CONSOLE_CACHE.mesh));
|
|
||||||
}
|
|
||||||
|
|
||||||
memoryZero(&UI_CONSOLE_CACHE, sizeof(uiconsolecache_t));
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "display/mesh/quad.h"
|
|
||||||
|
|
||||||
// Fixed capacity for the console's cached glyph mesh, in glyphs -- the
|
|
||||||
// mesh/vertex buffer is sized to this once and never grown/shrunk, so
|
|
||||||
// history producing more non-space characters than this asserts (see
|
|
||||||
// uiConsoleRebuild) instead of reallocating.
|
|
||||||
#define UI_CONSOLE_CACHE_GLYPH_MAX 512
|
|
||||||
#define UI_CONSOLE_CACHE_VERTEX_MAX (\
|
|
||||||
UI_CONSOLE_CACHE_GLYPH_MAX * QUAD_VERTEX_COUNT \
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the console history into the scan-safe area, drawing a mesh
|
|
||||||
* that is only rebuilt when the history changes or the scan-safe origin
|
|
||||||
* moves, rather than rebuffering vertices every frame. No-ops when the
|
|
||||||
* console is not visible.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiConsoleDraw(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuilds the cached mesh for the console's current history and
|
|
||||||
* scan-safe origin. Called automatically by uiConsoleDraw() whenever
|
|
||||||
* needed; only needs calling directly to force an immediate rebuild.
|
|
||||||
* The underlying vertex buffer is a fixed-size array (see
|
|
||||||
* UI_CONSOLE_CACHE_VERTEX_MAX) -- asserts if the history produces more
|
|
||||||
* glyphs than that capacity, rather than growing it.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiConsoleRebuild(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Frees the mesh and vertex buffer built up by uiConsoleDraw().
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiConsoleDispose(void);
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# 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
|
|
||||||
uiframe.c
|
|
||||||
uiconfirm.c
|
|
||||||
)
|
|
||||||
|
|
||||||
add_subdirectory(settings)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# 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
|
|
||||||
uisettings.c
|
|
||||||
uisettingsgeneral.c
|
|
||||||
uisettingsinput.c
|
|
||||||
uisettingsdisplay.c
|
|
||||||
uisettingsaudio.c
|
|
||||||
)
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uisettings.h"
|
|
||||||
#include "uisettingsgeneral.h"
|
|
||||||
#include "uisettingsinput.h"
|
|
||||||
#include "uisettingsdisplay.h"
|
|
||||||
#include "uisettingsaudio.h"
|
|
||||||
#include "ui/frame/uiframe.h"
|
|
||||||
#include "ui/frame/uiconfirm.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "display/text/text.h"
|
|
||||||
#include "display/screen/screen.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "locale/localemanager.h"
|
|
||||||
#include "asset/loader/locale/assetlocaleloader.h"
|
|
||||||
|
|
||||||
uisettings_t UI_SETTINGS;
|
|
||||||
|
|
||||||
uisettingstabdef_t UI_SETTINGS_TAB_DEFS[UI_SETTINGS_TAB_COUNT] = {
|
|
||||||
{
|
|
||||||
.localeId = "ui.settings.tabs.general",
|
|
||||||
.init = uiSettingsGeneralInit,
|
|
||||||
.menu = (uimenu_t *)&UI_SETTINGS.data,
|
|
||||||
.load = uiSettingsGeneralLoad,
|
|
||||||
.apply = uiSettingsGeneralApply,
|
|
||||||
.hasChanges = uiSettingsGeneralHasChanges
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
.localeId = "ui.settings.tabs.input",
|
|
||||||
.init = uiSettingsInputInit,
|
|
||||||
.menu = (uimenu_t *)&UI_SETTINGS.data,
|
|
||||||
.load = uiSettingsInputLoad,
|
|
||||||
.apply = uiSettingsInputApply,
|
|
||||||
.hasChanges = uiSettingsInputHasChanges
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
.localeId = "ui.settings.tabs.display",
|
|
||||||
.init = uiSettingsDisplayInit,
|
|
||||||
.menu = (uimenu_t *)&UI_SETTINGS.data,
|
|
||||||
.load = uiSettingsDisplayLoad,
|
|
||||||
.apply = uiSettingsDisplayApply,
|
|
||||||
.hasChanges = uiSettingsDisplayHasChanges
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
.localeId = "ui.settings.tabs.audio",
|
|
||||||
.init = uiSettingsAudioInit,
|
|
||||||
.menu = (uimenu_t *)&UI_SETTINGS.data,
|
|
||||||
.load = uiSettingsAudioLoad,
|
|
||||||
.apply = uiSettingsAudioApply,
|
|
||||||
.hasChanges = uiSettingsAudioHasChanges
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
static uisettingstabdef_t UI_SETTINGS_TOP_DEF = {
|
|
||||||
.menu = &UI_SETTINGS.tabsMenu,
|
|
||||||
.load = uiSettingsLoad,
|
|
||||||
.hasChanges = uiSettingsHasChanges
|
|
||||||
};
|
|
||||||
|
|
||||||
static uisettingstabdef_t *UI_SETTINGS_PENDING_DISCARD = NULL;
|
|
||||||
static uisettingstabdef_t *UI_SETTINGS_ACTIVE_TAB = NULL;
|
|
||||||
|
|
||||||
errorret_t uiSettingsActivateTab(uisettingstabdef_t *def) {
|
|
||||||
if(def == UI_SETTINGS_ACTIVE_TAB) errorOk();
|
|
||||||
|
|
||||||
errorChain(def->init(&UI_SETTINGS.data));
|
|
||||||
def->menu->user = def;
|
|
||||||
def->load();
|
|
||||||
UI_SETTINGS_ACTIVE_TAB = def;
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsTabChanged(
|
|
||||||
const uimenu_t *menu,
|
|
||||||
const uint8_t index,
|
|
||||||
const uimenuitem_t *item
|
|
||||||
) {
|
|
||||||
if(index >= UI_SETTINGS_TAB_COUNT) return;
|
|
||||||
errorCatch(uiSettingsActivateTab(&UI_SETTINGS_TAB_DEFS[index]));
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsTabSelected(
|
|
||||||
const uimenu_t *menu,
|
|
||||||
const uint8_t index,
|
|
||||||
const uimenuitem_t *item
|
|
||||||
) {
|
|
||||||
if(index >= UI_SETTINGS_TAB_COUNT) return;
|
|
||||||
|
|
||||||
uisettingstabdef_t *def = &UI_SETTINGS_TAB_DEFS[index];
|
|
||||||
errorCatch(uiSettingsActivateTab(def));
|
|
||||||
uiMenuOpen(def->menu);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiSettingsInit(void) {
|
|
||||||
memoryZero(&UI_SETTINGS, sizeof(uisettings_t));
|
|
||||||
UI_SETTINGS_ACTIVE_TAB = NULL;
|
|
||||||
|
|
||||||
for(uint8_t i = 0; i < UI_SETTINGS_TAB_COUNT; i++) {
|
|
||||||
errorChain(assetLocaleGetString(
|
|
||||||
&LOCALE.entry->data.locale,
|
|
||||||
UI_SETTINGS_TAB_DEFS[i].localeId,
|
|
||||||
0,
|
|
||||||
UI_SETTINGS.tabLabels[i],
|
|
||||||
UI_SETTINGS_TAB_LABEL_MAX
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
MENU_BEGIN(
|
|
||||||
&UI_SETTINGS.tabsMenu, UI_SETTINGS.tabs, uiSettingsTabSelected,
|
|
||||||
uiSettingsDiscardMenuClosed, uiSettingsTabChanged
|
|
||||||
);
|
|
||||||
for(uint8_t i = 0; i < UI_SETTINGS_TAB_COUNT; i++) {
|
|
||||||
MENU_TAB(UI_SETTINGS.tabLabels[i]);
|
|
||||||
}
|
|
||||||
MENU_END(UI_SETTINGS.tabs, menuIndex);
|
|
||||||
|
|
||||||
UI_SETTINGS.tabsMenu.user = &UI_SETTINGS_TOP_DEF;
|
|
||||||
|
|
||||||
errorChain(assetLocaleGetString(
|
|
||||||
&LOCALE.entry->data.locale,
|
|
||||||
"ui.settings.apply",
|
|
||||||
0,
|
|
||||||
UI_SETTINGS.applyLabel,
|
|
||||||
UI_SETTINGS_APPLY_LABEL_MAX
|
|
||||||
));
|
|
||||||
|
|
||||||
errorChain(uiSettingsActivateTab(&UI_SETTINGS_TAB_DEFS[0]));
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiSettingsDraw(void) {
|
|
||||||
if(!uiMenuIsActive(&UI_SETTINGS.tabsMenu)) errorOk();
|
|
||||||
|
|
||||||
const float_t width = SCREEN.width;
|
|
||||||
const float_t height = SCREEN.height;
|
|
||||||
const float_t x = (float_t)SCREEN.scanX +
|
|
||||||
((float_t)SCREEN.scanWidth - width) * 0.5f;
|
|
||||||
const float_t y = (float_t)SCREEN.scanY +
|
|
||||||
((float_t)SCREEN.scanHeight - height) * 0.5f;
|
|
||||||
|
|
||||||
errorChain(uiFrameDrawCached(&UI_SETTINGS.frameCache, x, y, width, height));
|
|
||||||
|
|
||||||
const float_t contentX = x + UI_FRAME_START_X;
|
|
||||||
const float_t contentY = y + UI_FRAME_START_Y;
|
|
||||||
const float_t contentWidth = width - (UI_FRAME_START_X * 2);
|
|
||||||
const float_t contentHeight = height - (UI_FRAME_START_Y * 2);
|
|
||||||
|
|
||||||
errorChain(uiMenuDraw(
|
|
||||||
&UI_SETTINGS.tabsMenu,
|
|
||||||
contentX,
|
|
||||||
contentY,
|
|
||||||
contentWidth,
|
|
||||||
contentHeight
|
|
||||||
));
|
|
||||||
|
|
||||||
const float_t tabsRowHeight = (float_t)FONT_DEFAULT.tileset->tileHeight;
|
|
||||||
const float_t pageY = contentY + tabsRowHeight + UI_FRAME_PADDING_Y;
|
|
||||||
const float_t pageHeight = contentHeight - tabsRowHeight - UI_FRAME_PADDING_Y;
|
|
||||||
|
|
||||||
if(UI_SETTINGS_ACTIVE_TAB != NULL) {
|
|
||||||
errorChain(uiMenuDraw(
|
|
||||||
UI_SETTINGS_ACTIVE_TAB->menu, contentX, pageY, contentWidth, pageHeight
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiSettingsUpdate(void) {
|
|
||||||
if(!UI_SETTINGS_PENDING_DISCARD) errorOk();
|
|
||||||
|
|
||||||
uisettingstabdef_t *def = UI_SETTINGS_PENDING_DISCARD;
|
|
||||||
UI_SETTINGS_PENDING_DISCARD = NULL;
|
|
||||||
|
|
||||||
return uiSettingsDiscardUpdate(def);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t uiSettingsIsOpen(void) {
|
|
||||||
return uiMenuIsActive(&UI_SETTINGS.tabsMenu);
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsOpen() {
|
|
||||||
uiSettingsLoad();
|
|
||||||
uiMenuOpen(&UI_SETTINGS.tabsMenu);
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsClose() {
|
|
||||||
uiMenuClose(&UI_SETTINGS.tabsMenu);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiSettingsDispose(void) {
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsLoad(void) {
|
|
||||||
if(UI_SETTINGS_ACTIVE_TAB != NULL) UI_SETTINGS_ACTIVE_TAB->load();
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsApply(void) {
|
|
||||||
if(UI_SETTINGS_ACTIVE_TAB != NULL) UI_SETTINGS_ACTIVE_TAB->apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t uiSettingsHasChanges(void) {
|
|
||||||
return UI_SETTINGS_ACTIVE_TAB != NULL && UI_SETTINGS_ACTIVE_TAB->hasChanges();
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsDiscardMenuClosed(const uimenu_t *menu) {
|
|
||||||
uisettingstabdef_t *def = (uisettingstabdef_t *)menu->user;
|
|
||||||
if(def->hasChanges()) UI_SETTINGS_PENDING_DISCARD = def;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiSettingsDiscardUpdate(uisettingstabdef_t *def) {
|
|
||||||
#define UI_SETTINGS_DISCARD_TEXT_MAX 128
|
|
||||||
char_t text[UI_SETTINGS_DISCARD_TEXT_MAX];
|
|
||||||
errorChain(assetLocaleGetString(
|
|
||||||
&LOCALE.entry->data.locale,
|
|
||||||
"ui.confirm.discard_changes",
|
|
||||||
0,
|
|
||||||
text,
|
|
||||||
UI_SETTINGS_DISCARD_TEXT_MAX
|
|
||||||
));
|
|
||||||
#undef UI_SETTINGS_DISCARD_TEXT_MAX
|
|
||||||
|
|
||||||
uiConfirmOpen(text, uiSettingsDiscardConfirmResult, def);
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsDiscardConfirmResult(const bool_t result, void *user) {
|
|
||||||
uisettingstabdef_t *def = (uisettingstabdef_t *)user;
|
|
||||||
|
|
||||||
if(!result) {
|
|
||||||
uiMenuOpen(def->menu);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
def->load();
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "ui/widget/uimenu.h"
|
|
||||||
#include "ui/frame/uiframe.h"
|
|
||||||
#include "uisettingsdata.h"
|
|
||||||
|
|
||||||
#define UI_SETTINGS_TAB_COUNT 4
|
|
||||||
#define UI_SETTINGS_TAB_LABEL_MAX 32
|
|
||||||
#define UI_SETTINGS_APPLY_LABEL_MAX 32
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
const char_t *localeId;
|
|
||||||
errorret_t (*init)(uisettingsdata_t *data);
|
|
||||||
uimenu_t *menu;
|
|
||||||
void (*load)(void);
|
|
||||||
void (*apply)(void);
|
|
||||||
bool_t (*hasChanges)(void);
|
|
||||||
} uisettingstabdef_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uimenu_t tabsMenu;
|
|
||||||
uimenuitem_t tabs[UI_SETTINGS_TAB_COUNT];
|
|
||||||
char_t tabLabels[UI_SETTINGS_TAB_COUNT][UI_SETTINGS_TAB_LABEL_MAX];
|
|
||||||
char_t applyLabel[UI_SETTINGS_APPLY_LABEL_MAX];
|
|
||||||
uisettingsdata_t data;
|
|
||||||
uiframecache_t frameCache;
|
|
||||||
} uisettings_t;
|
|
||||||
|
|
||||||
extern uisettings_t UI_SETTINGS;
|
|
||||||
extern uisettingstabdef_t UI_SETTINGS_TAB_DEFS[UI_SETTINGS_TAB_COUNT];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menu closed callback that flags a pending discard-changes
|
|
||||||
* confirmation if the menu's settings page/panel reports unsaved
|
|
||||||
* changes. Pass this directly as a settings page menu's closed
|
|
||||||
* callback. The pending def is picked up by uiSettingsUpdate on the
|
|
||||||
* next tick.
|
|
||||||
*
|
|
||||||
* @param menu The menu that was just closed.
|
|
||||||
*/
|
|
||||||
void uiSettingsDiscardMenuClosed(const uimenu_t *menu);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the discard-changes confirmation dialog for def. Called once
|
|
||||||
* by uiSettingsUpdate when uiSettingsDiscardMenuClosed has flagged a
|
|
||||||
* pending def.
|
|
||||||
*
|
|
||||||
* @param def The tab/panel def to show the confirmation for.
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiSettingsDiscardUpdate(uisettingstabdef_t *def);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback passed to uiConfirmOpen by uiSettingsDiscardUpdate. Reopens
|
|
||||||
* the menu if the user chose to keep editing, otherwise reloads the
|
|
||||||
* widgets back to the actual engine values.
|
|
||||||
*
|
|
||||||
* @param result True if the user confirmed discarding changes, false
|
|
||||||
* to keep editing.
|
|
||||||
* @param user The uisettingstabdef_t* this confirmation belongs to.
|
|
||||||
*/
|
|
||||||
void uiSettingsDiscardConfirmResult(const bool_t result, void *user);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the settings panel and all of its category pages.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiSettingsInit(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws the settings panel. No-op when not visible.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiSettingsDraw(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the settings panel. Shows a discard-changes confirmation if
|
|
||||||
* the panel was just closed with unsaved changes.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiSettingsUpdate(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true when the settings panel is currently open.
|
|
||||||
*
|
|
||||||
* @returns True if open.
|
|
||||||
*/
|
|
||||||
bool_t uiSettingsIsOpen(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the settings panel. No-op when already open.
|
|
||||||
*/
|
|
||||||
void uiSettingsOpen();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Closes the settings panel. No-op when already closed.
|
|
||||||
*/
|
|
||||||
void uiSettingsClose();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disposes of the settings panel.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiSettingsDispose(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads every category page's widgets from the current engine settings.
|
|
||||||
* Called automatically by uiSettingsOpen.
|
|
||||||
*/
|
|
||||||
void uiSettingsLoad(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies every category page's widget values to the engine settings.
|
|
||||||
* Called automatically when a page's own Apply button is pressed.
|
|
||||||
*/
|
|
||||||
void uiSettingsApply(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether any category page has a widget value that differs
|
|
||||||
* from what was loaded.
|
|
||||||
*
|
|
||||||
* @returns True if there are unsaved changes.
|
|
||||||
*/
|
|
||||||
bool_t uiSettingsHasChanges(void);
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uisettingsaudio.h"
|
|
||||||
#include "uisettings.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
|
|
||||||
void uiSettingsAudioSelected(
|
|
||||||
const uimenu_t *menu,
|
|
||||||
const uint8_t index,
|
|
||||||
const uimenuitem_t *item
|
|
||||||
) {
|
|
||||||
if(index != UI_SETTINGS_AUDIO_INDEX_APPLY) return;
|
|
||||||
uiSettingsAudioApply();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiSettingsAudioInit(uisettingsdata_t *data) {
|
|
||||||
uisettingsaudio_t *audio = &data->audio;
|
|
||||||
memoryZero(audio, sizeof(uisettingsaudio_t));
|
|
||||||
|
|
||||||
MENU_BEGIN(
|
|
||||||
&audio->menu, audio->items,
|
|
||||||
uiSettingsAudioSelected, uiSettingsDiscardMenuClosed, NULL
|
|
||||||
);
|
|
||||||
MENU_LABEL("No audio settings yet");
|
|
||||||
MENU_BUTTON(UI_SETTINGS.applyLabel);
|
|
||||||
|
|
||||||
MENU_END(audio->items, 1);
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsAudioLoad(void) {
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsAudioApply(void) {
|
|
||||||
uiMenuClose(&UI_SETTINGS.data.audio.menu);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t uiSettingsAudioHasChanges(void) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "uisettingsdata.h"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the audio settings page into the shared settings data
|
|
||||||
* union.
|
|
||||||
*
|
|
||||||
* @param data The shared settings data union to initialize into.
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiSettingsAudioInit(uisettingsdata_t *data);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the page's widgets from the current engine audio settings.
|
|
||||||
*/
|
|
||||||
void uiSettingsAudioLoad(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies the page's widget values to the engine audio settings.
|
|
||||||
*/
|
|
||||||
void uiSettingsAudioApply(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether any widget's value differs from what was loaded.
|
|
||||||
*
|
|
||||||
* @returns True if there are unsaved changes.
|
|
||||||
*/
|
|
||||||
bool_t uiSettingsAudioHasChanges(void);
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "ui/widget/uimenu.h"
|
|
||||||
|
|
||||||
#define UI_SETTINGS_GENERAL_ITEM_COUNT 3
|
|
||||||
#define UI_SETTINGS_GENERAL_INDEX_LANGUAGE 0
|
|
||||||
#define UI_SETTINGS_GENERAL_INDEX_LANGUAGE_DETAIL 1
|
|
||||||
#define UI_SETTINGS_GENERAL_INDEX_APPLY 2
|
|
||||||
#define UI_SETTINGS_GENERAL_LOCALE_COUNT 3
|
|
||||||
#define UI_SETTINGS_GENERAL_LABEL_MAX 32
|
|
||||||
#define UI_SETTINGS_GENERAL_INFO_MAX 64
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uimenu_t menu;
|
|
||||||
uimenuitem_t items[UI_SETTINGS_GENERAL_ITEM_COUNT];
|
|
||||||
const char_t *localeNames[UI_SETTINGS_GENERAL_LOCALE_COUNT];
|
|
||||||
char_t languageLabel[UI_SETTINGS_GENERAL_LABEL_MAX];
|
|
||||||
char_t labelInfo[UI_SETTINGS_GENERAL_INFO_MAX];
|
|
||||||
} uisettingsgeneral_t;
|
|
||||||
|
|
||||||
#define UI_SETTINGS_INPUT_ITEM_COUNT 2
|
|
||||||
#define UI_SETTINGS_INPUT_LABEL_MAX 32
|
|
||||||
#define UI_SETTINGS_INPUT_INDEX_DEADZONE 0
|
|
||||||
#define UI_SETTINGS_INPUT_INDEX_APPLY 1
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uimenu_t menu;
|
|
||||||
uimenuitem_t items[UI_SETTINGS_INPUT_ITEM_COUNT];
|
|
||||||
char_t deadzoneLabel[UI_SETTINGS_INPUT_LABEL_MAX];
|
|
||||||
} uisettingsinput_t;
|
|
||||||
|
|
||||||
#define UI_SETTINGS_DISPLAY_ITEM_COUNT 2
|
|
||||||
#define UI_SETTINGS_DISPLAY_INDEX_APPLY 1
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uimenu_t menu;
|
|
||||||
uimenuitem_t items[UI_SETTINGS_DISPLAY_ITEM_COUNT];
|
|
||||||
} uisettingsdisplay_t;
|
|
||||||
|
|
||||||
#define UI_SETTINGS_AUDIO_ITEM_COUNT 2
|
|
||||||
#define UI_SETTINGS_AUDIO_INDEX_APPLY 1
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uimenu_t menu;
|
|
||||||
uimenuitem_t items[UI_SETTINGS_AUDIO_ITEM_COUNT];
|
|
||||||
} uisettingsaudio_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared, overlapping storage for every settings tab's page data. Only
|
|
||||||
* one member is ever live at a time -- the active tab's Init writes
|
|
||||||
* into it, overwriting whatever the previously active tab left there.
|
|
||||||
* This trades simultaneous access to every tab for a memory footprint
|
|
||||||
* of only the single largest page.
|
|
||||||
*/
|
|
||||||
typedef union {
|
|
||||||
uisettingsgeneral_t general;
|
|
||||||
uisettingsinput_t input;
|
|
||||||
uisettingsdisplay_t display;
|
|
||||||
uisettingsaudio_t audio;
|
|
||||||
} uisettingsdata_t;
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uisettingsdisplay.h"
|
|
||||||
#include "uisettings.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
|
|
||||||
void uiSettingsDisplaySelected(
|
|
||||||
const uimenu_t *menu,
|
|
||||||
const uint8_t index,
|
|
||||||
const uimenuitem_t *item
|
|
||||||
) {
|
|
||||||
if(index != UI_SETTINGS_DISPLAY_INDEX_APPLY) return;
|
|
||||||
uiSettingsDisplayApply();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiSettingsDisplayInit(uisettingsdata_t *data) {
|
|
||||||
uisettingsdisplay_t *display = &data->display;
|
|
||||||
memoryZero(display, sizeof(uisettingsdisplay_t));
|
|
||||||
|
|
||||||
MENU_BEGIN(
|
|
||||||
&display->menu, display->items,
|
|
||||||
uiSettingsDisplaySelected, uiSettingsDiscardMenuClosed, NULL
|
|
||||||
);
|
|
||||||
MENU_LABEL("No display settings yet");
|
|
||||||
MENU_BUTTON(UI_SETTINGS.applyLabel);
|
|
||||||
|
|
||||||
MENU_END(display->items, 1);
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsDisplayLoad(void) {
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsDisplayApply(void) {
|
|
||||||
uiMenuClose(&UI_SETTINGS.data.display.menu);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t uiSettingsDisplayHasChanges(void) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "uisettingsdata.h"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the display settings page into the shared settings data
|
|
||||||
* union.
|
|
||||||
*
|
|
||||||
* @param data The shared settings data union to initialize into.
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiSettingsDisplayInit(uisettingsdata_t *data);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the page's widgets from the current engine display settings.
|
|
||||||
*/
|
|
||||||
void uiSettingsDisplayLoad(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies the page's widget values to the engine display settings.
|
|
||||||
*/
|
|
||||||
void uiSettingsDisplayApply(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether any widget's value differs from what was loaded.
|
|
||||||
*
|
|
||||||
* @returns True if there are unsaved changes.
|
|
||||||
*/
|
|
||||||
bool_t uiSettingsDisplayHasChanges(void);
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uisettingsgeneral.h"
|
|
||||||
#include "uisettings.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "util/string.h"
|
|
||||||
#include "locale/localemanager.h"
|
|
||||||
#include "locale/localeinfo.h"
|
|
||||||
#include "asset/loader/locale/assetlocaleloader.h"
|
|
||||||
|
|
||||||
static const localeinfo_t *const UI_SETTINGS_GENERAL_LOCALES[
|
|
||||||
UI_SETTINGS_GENERAL_LOCALE_COUNT
|
|
||||||
] = {
|
|
||||||
&LOCALE_EN_US,
|
|
||||||
&LOCALE_JP_JP,
|
|
||||||
&LOCALE_ES_MX
|
|
||||||
};
|
|
||||||
|
|
||||||
void uiSettingsGeneralSelected(
|
|
||||||
const uimenu_t *menu,
|
|
||||||
const uint8_t index,
|
|
||||||
const uimenuitem_t *item
|
|
||||||
) {
|
|
||||||
switch(index) {
|
|
||||||
case UI_SETTINGS_GENERAL_INDEX_APPLY:
|
|
||||||
uiSettingsGeneralApply();
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiSettingsGeneralInit(uisettingsdata_t *data) {
|
|
||||||
uisettingsgeneral_t *general = &data->general;
|
|
||||||
memoryZero(general, sizeof(uisettingsgeneral_t));
|
|
||||||
|
|
||||||
for(uint8_t i = 0; i < UI_SETTINGS_GENERAL_LOCALE_COUNT; i++) {
|
|
||||||
general->localeNames[i] = UI_SETTINGS_GENERAL_LOCALES[i]->name;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorChain(assetLocaleGetString(
|
|
||||||
&LOCALE.entry->data.locale,
|
|
||||||
"ui.settings.general.language",
|
|
||||||
0,
|
|
||||||
general->languageLabel,
|
|
||||||
UI_SETTINGS_GENERAL_LABEL_MAX
|
|
||||||
));
|
|
||||||
errorChain(assetLocaleGetString(
|
|
||||||
&LOCALE.entry->data.locale,
|
|
||||||
"ui.settings.general.language_detail",
|
|
||||||
0,
|
|
||||||
general->labelInfo,
|
|
||||||
UI_SETTINGS_GENERAL_INFO_MAX
|
|
||||||
));
|
|
||||||
|
|
||||||
MENU_BEGIN(
|
|
||||||
&general->menu, general->items,
|
|
||||||
uiSettingsGeneralSelected, uiSettingsDiscardMenuClosed, NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
MENU_DROPDOWN(
|
|
||||||
general->languageLabel, general->localeNames,
|
|
||||||
UI_SETTINGS_GENERAL_LOCALE_COUNT, 0
|
|
||||||
);
|
|
||||||
MENU_LABEL(general->labelInfo);
|
|
||||||
|
|
||||||
MENU_BUTTON(UI_SETTINGS.applyLabel);
|
|
||||||
MENU_END(general->items, 1);
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsGeneralLoad(void) {
|
|
||||||
uisettingsgeneral_t *general = &UI_SETTINGS.data.general;
|
|
||||||
|
|
||||||
uint8_t index = 0;
|
|
||||||
for(uint8_t i = 0; i < UI_SETTINGS_GENERAL_LOCALE_COUNT; i++) {
|
|
||||||
if(stringCompare(LOCALE.locale->file, UI_SETTINGS_GENERAL_LOCALES[i]->file) != 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
index = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
uiDropdownSetSelectedIndex(
|
|
||||||
&general->items[UI_SETTINGS_GENERAL_INDEX_LANGUAGE].dropdown, index
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsGeneralApply(void) {
|
|
||||||
uisettingsgeneral_t *general = &UI_SETTINGS.data.general;
|
|
||||||
|
|
||||||
uint8_t index = uiDropdownGetSelectedIndex(
|
|
||||||
&general->items[UI_SETTINGS_GENERAL_INDEX_LANGUAGE].dropdown
|
|
||||||
);
|
|
||||||
errorCatch(localeManagerSetLocale(UI_SETTINGS_GENERAL_LOCALES[index]));
|
|
||||||
|
|
||||||
uiMenuClose(&general->menu);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t uiSettingsGeneralHasChanges(void) {
|
|
||||||
uisettingsgeneral_t *general = &UI_SETTINGS.data.general;
|
|
||||||
|
|
||||||
uint8_t index = uiDropdownGetSelectedIndex(
|
|
||||||
&general->items[UI_SETTINGS_GENERAL_INDEX_LANGUAGE].dropdown
|
|
||||||
);
|
|
||||||
return stringCompare(
|
|
||||||
LOCALE.locale->file, UI_SETTINGS_GENERAL_LOCALES[index]->file
|
|
||||||
) != 0;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "uisettingsdata.h"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the general settings page into the shared settings data
|
|
||||||
* union.
|
|
||||||
*
|
|
||||||
* @param data The shared settings data union to initialize into.
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiSettingsGeneralInit(uisettingsdata_t *data);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the page's widgets from the current engine general settings.
|
|
||||||
*/
|
|
||||||
void uiSettingsGeneralLoad(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies the page's widget values to the engine general settings.
|
|
||||||
*/
|
|
||||||
void uiSettingsGeneralApply(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether any widget's value differs from what was loaded.
|
|
||||||
*
|
|
||||||
* @returns True if there are unsaved changes.
|
|
||||||
*/
|
|
||||||
bool_t uiSettingsGeneralHasChanges(void);
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uisettingsinput.h"
|
|
||||||
#include "uisettings.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "locale/localemanager.h"
|
|
||||||
#include "asset/loader/locale/assetlocaleloader.h"
|
|
||||||
#include "input/input.h"
|
|
||||||
#include "save/savesettings.h"
|
|
||||||
|
|
||||||
void uiSettingsInputSelected(
|
|
||||||
const uimenu_t *menu,
|
|
||||||
const uint8_t index,
|
|
||||||
const uimenuitem_t *item
|
|
||||||
) {
|
|
||||||
if(index != UI_SETTINGS_INPUT_INDEX_APPLY) return;
|
|
||||||
uiSettingsInputApply();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiSettingsInputInit(uisettingsdata_t *data) {
|
|
||||||
uisettingsinput_t *input = &data->input;
|
|
||||||
memoryZero(input, sizeof(uisettingsinput_t));
|
|
||||||
|
|
||||||
MENU_BEGIN(
|
|
||||||
&input->menu, input->items,
|
|
||||||
uiSettingsInputSelected, uiSettingsDiscardMenuClosed, NULL
|
|
||||||
);
|
|
||||||
#ifdef DUSK_INPUT_GAMEPAD
|
|
||||||
errorChain(assetLocaleGetString(
|
|
||||||
&LOCALE.entry->data.locale,
|
|
||||||
"ui.settings.input.deadzone",
|
|
||||||
0,
|
|
||||||
input->deadzoneLabel,
|
|
||||||
UI_SETTINGS_INPUT_LABEL_MAX
|
|
||||||
));
|
|
||||||
MENU_SLIDER_FLOAT(
|
|
||||||
input->deadzoneLabel, INPUT_DEADZONE_DEFAULT, 0.0f, 1.0f, 0.05f
|
|
||||||
);
|
|
||||||
#else
|
|
||||||
MENU_LABEL("No input settings yet");
|
|
||||||
#endif
|
|
||||||
|
|
||||||
MENU_BUTTON(UI_SETTINGS.applyLabel);
|
|
||||||
MENU_END(input->items, 1);
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsInputLoad(void) {
|
|
||||||
#ifdef DUSK_INPUT_GAMEPAD
|
|
||||||
uiSliderSetFloat(
|
|
||||||
&UI_SETTINGS.data.input.items[UI_SETTINGS_INPUT_INDEX_DEADZONE].slider,
|
|
||||||
INPUT.deadzone
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiSettingsInputApply(void) {
|
|
||||||
#ifdef DUSK_INPUT_GAMEPAD
|
|
||||||
INPUT.deadzone = uiSliderGetFloat(
|
|
||||||
&UI_SETTINGS.data.input.items[UI_SETTINGS_INPUT_INDEX_DEADZONE].slider
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
errorCatch(errorPrint(saveSettingsWrite()));
|
|
||||||
uiMenuClose(&UI_SETTINGS.data.input.menu);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t uiSettingsInputHasChanges(void) {
|
|
||||||
#ifdef DUSK_INPUT_GAMEPAD
|
|
||||||
if(uiSliderGetFloat(
|
|
||||||
&UI_SETTINGS.data.input.items[UI_SETTINGS_INPUT_INDEX_DEADZONE].slider
|
|
||||||
) != INPUT.deadzone) return true;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "uisettingsdata.h"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the input settings page into the shared settings data
|
|
||||||
* union.
|
|
||||||
*
|
|
||||||
* @param data The shared settings data union to initialize into.
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiSettingsInputInit(uisettingsdata_t *data);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the page's widgets from the current engine input settings.
|
|
||||||
*/
|
|
||||||
void uiSettingsInputLoad(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies the page's widget values to the engine input settings.
|
|
||||||
*/
|
|
||||||
void uiSettingsInputApply(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether any widget's value differs from what was loaded.
|
|
||||||
*
|
|
||||||
* @returns True if there are unsaved changes.
|
|
||||||
*/
|
|
||||||
bool_t uiSettingsInputHasChanges(void);
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uiconfirm.h"
|
|
||||||
#include "uiframe.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "util/math.h"
|
|
||||||
#include "display/screen/screen.h"
|
|
||||||
#include "display/text/text.h"
|
|
||||||
#include "display/color.h"
|
|
||||||
#include "display/spritebatch/spritebatch.h"
|
|
||||||
#include "display/texture/texture.h"
|
|
||||||
#include "display/shader/shaderunlit.h"
|
|
||||||
|
|
||||||
#define UI_CONFIRM_BACKDROP_COLOR color4b(0, 0, 0, 160)
|
|
||||||
|
|
||||||
uiconfirm_t UI_CONFIRM;
|
|
||||||
|
|
||||||
void uiConfirmSelected(
|
|
||||||
const uimenu_t *menu,
|
|
||||||
const uint8_t index,
|
|
||||||
const uimenuitem_t *item
|
|
||||||
) {
|
|
||||||
UI_CONFIRM.result = index == UI_CONFIRM_INDEX_CONFIRM;
|
|
||||||
uiConfirmClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiConfirmClosed(const uimenu_t *menu) {
|
|
||||||
if(UI_CONFIRM.callback != NULL) {
|
|
||||||
UI_CONFIRM.callback(UI_CONFIRM.result, UI_CONFIRM.user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiConfirmInit(void) {
|
|
||||||
memoryZero(&UI_CONFIRM, sizeof(uiconfirm_t));
|
|
||||||
uiLabelInit(&UI_CONFIRM.textLabel, &FONT_DEFAULT);
|
|
||||||
|
|
||||||
MENU_BEGIN(
|
|
||||||
&UI_CONFIRM.menu, UI_CONFIRM.items, uiConfirmSelected, uiConfirmClosed, NULL
|
|
||||||
);
|
|
||||||
MENU_BUTTON("Confirm");
|
|
||||||
MENU_BUTTON("Cancel");
|
|
||||||
|
|
||||||
MENU_END(UI_CONFIRM.items, menuIndex);
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiConfirmDraw(void) {
|
|
||||||
if(!uiMenuIsActive(&UI_CONFIRM.menu)) errorOk();
|
|
||||||
|
|
||||||
spritebatchsprite_t backdropSprite = {
|
|
||||||
.min = { 0.0f, 0.0f, 0.0f },
|
|
||||||
.max = { (float_t)SCREEN.width, (float_t)SCREEN.height, 0.0f },
|
|
||||||
.uvMin = { 0.0f, 0.0f },
|
|
||||||
.uvMax = { 1.0f, 1.0f }
|
|
||||||
};
|
|
||||||
shadermaterial_t backdropMaterial = {
|
|
||||||
.unlit = {
|
|
||||||
.color = UI_CONFIRM_BACKDROP_COLOR,
|
|
||||||
.texture = &TEXTURE_WHITE
|
|
||||||
}
|
|
||||||
};
|
|
||||||
errorChain(
|
|
||||||
spriteBatchBuffer(&backdropSprite, 1, &SHADER_UNLIT, backdropMaterial)
|
|
||||||
);
|
|
||||||
|
|
||||||
int32_t textW, textH;
|
|
||||||
uiLabelGetSize(&UI_CONFIRM.textLabel, &textW, &textH);
|
|
||||||
|
|
||||||
float_t rowHeight = (float_t)FONT_DEFAULT.tileset->tileHeight;
|
|
||||||
float_t width = mathMax(
|
|
||||||
(float_t)textW + (UI_FRAME_START_X * 2), UI_CONFIRM_MIN_WIDTH
|
|
||||||
);
|
|
||||||
float_t height = (UI_FRAME_START_Y * 2) + rowHeight + UI_FRAME_PADDING_Y +
|
|
||||||
rowHeight;
|
|
||||||
float_t x = (float_t)SCREEN.scanX + ((float_t)SCREEN.scanWidth - width) * 0.5f;
|
|
||||||
float_t y = (float_t)SCREEN.scanY + ((float_t)SCREEN.scanHeight - height) * 0.5f;
|
|
||||||
|
|
||||||
errorChain(uiFrameDrawCached(&UI_CONFIRM.frameCache, x, y, width, height));
|
|
||||||
|
|
||||||
float_t contentX = x + UI_FRAME_START_X;
|
|
||||||
float_t contentY = y + UI_FRAME_START_Y;
|
|
||||||
float_t contentWidth = width - (UI_FRAME_START_X * 2);
|
|
||||||
|
|
||||||
errorChain(uiLabelDraw(&UI_CONFIRM.textLabel, contentX, contentY));
|
|
||||||
|
|
||||||
float_t buttonsY = contentY + rowHeight + UI_FRAME_PADDING_Y;
|
|
||||||
errorChain(uiMenuDraw(&UI_CONFIRM.menu, contentX, buttonsY, contentWidth, rowHeight));
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t uiConfirmIsOpen(void) {
|
|
||||||
return uiMenuIsActive(&UI_CONFIRM.menu);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t uiConfirmGetResult(void) {
|
|
||||||
return UI_CONFIRM.result;
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiConfirmOpen(
|
|
||||||
const char_t *question,
|
|
||||||
uiconfirmcallback_t callback,
|
|
||||||
void *user
|
|
||||||
) {
|
|
||||||
assertNotNull(question, "Question cannot be NULL");
|
|
||||||
uiLabelSetText(&UI_CONFIRM.textLabel, question);
|
|
||||||
UI_CONFIRM.callback = callback;
|
|
||||||
UI_CONFIRM.user = user;
|
|
||||||
UI_CONFIRM.result = false;
|
|
||||||
uiMenuOpen(&UI_CONFIRM.menu);
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiConfirmClose(void) {
|
|
||||||
uiMenuClose(&UI_CONFIRM.menu);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiConfirmDispose(void) {
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "ui/widget/uimenu.h"
|
|
||||||
#include "ui/widget/uilabel.h"
|
|
||||||
#include "ui/frame/uiframe.h"
|
|
||||||
|
|
||||||
#define UI_CONFIRM_MIN_WIDTH 160.0f
|
|
||||||
#define UI_CONFIRM_INDEX_CONFIRM 0
|
|
||||||
#define UI_CONFIRM_INDEX_CANCEL 1
|
|
||||||
#define UI_CONFIRM_ITEM_COUNT 2
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback invoked once a confirm dialog is dismissed.
|
|
||||||
*
|
|
||||||
* @param result True if Confirm was selected, false if Cancel was
|
|
||||||
* selected or the dialog was backed out of.
|
|
||||||
* @param user Arbitrary pointer passed to uiConfirmOpen.
|
|
||||||
*/
|
|
||||||
typedef void (*uiconfirmcallback_t)(const bool_t result, void *user);
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uilabel_t textLabel;
|
|
||||||
uimenu_t menu;
|
|
||||||
uimenuitem_t items[UI_CONFIRM_ITEM_COUNT];
|
|
||||||
uiconfirmcallback_t callback;
|
|
||||||
void *user;
|
|
||||||
bool_t result;
|
|
||||||
uiframecache_t frameCache;
|
|
||||||
} uiconfirm_t;
|
|
||||||
|
|
||||||
extern uiconfirm_t UI_CONFIRM;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the confirm dialog.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiConfirmInit(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws the confirm dialog: a semi-transparent black backdrop covering
|
|
||||||
* the whole screen, then its own centered frame with the question text
|
|
||||||
* and Confirm/Cancel buttons. No-op when not open.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiConfirmDraw(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true when the confirm dialog is currently open.
|
|
||||||
*
|
|
||||||
* @returns True if open.
|
|
||||||
*/
|
|
||||||
bool_t uiConfirmIsOpen(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the result of the most recently dismissed confirm dialog.
|
|
||||||
*
|
|
||||||
* @returns True if Confirm was selected, false otherwise.
|
|
||||||
*/
|
|
||||||
bool_t uiConfirmGetResult(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the confirm dialog with the given question text. callback is
|
|
||||||
* invoked exactly once with the result, whether the dialog was
|
|
||||||
* dismissed by selecting a button or by pressing cancel/back.
|
|
||||||
*
|
|
||||||
* @param question Display text; copied internally, safe to be transient.
|
|
||||||
* @param callback Called with the result once the dialog closes. May be
|
|
||||||
* NULL.
|
|
||||||
* @param user Arbitrary pointer passed through to callback.
|
|
||||||
*/
|
|
||||||
void uiConfirmOpen(
|
|
||||||
const char_t *question,
|
|
||||||
uiconfirmcallback_t callback,
|
|
||||||
void *user
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Closes the confirm dialog. No-op when already closed.
|
|
||||||
*/
|
|
||||||
void uiConfirmClose(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disposes of the confirm dialog.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiConfirmDispose(void);
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uiframe.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "display/texture/texture.h"
|
|
||||||
#include "display/spritebatch/spritebatch.h"
|
|
||||||
#include "display/shader/shaderunlit.h"
|
|
||||||
#include "display/color.h"
|
|
||||||
|
|
||||||
uiframe_t UI_FRAME;
|
|
||||||
|
|
||||||
errorret_t uiFrameInit(void) {
|
|
||||||
memoryZero(&UI_FRAME, sizeof(uiframe_t));
|
|
||||||
|
|
||||||
// Init texture.
|
|
||||||
color_t border = color4b(0, 50, 255, 255);
|
|
||||||
color_t center = color4b(0, 100, 220, 240);
|
|
||||||
|
|
||||||
for(uint8_t y = 0; y < UI_FRAME_TEXTURE_HEIGHT_POW2; y++) {
|
|
||||||
for(uint8_t x = 0; x < UI_FRAME_TEXTURE_WIDTH_POW2; x++) {
|
|
||||||
color_t c;
|
|
||||||
if(x >= UI_FRAME_TEXTURE_WIDTH || y >= UI_FRAME_TEXTURE_HEIGHT) {
|
|
||||||
c = COLOR_TRANSPARENT;
|
|
||||||
} else if(
|
|
||||||
y < UI_FRAME_TILE_HEIGHT || y >= UI_FRAME_TILE_HEIGHT * 2 ||
|
|
||||||
x < UI_FRAME_TILE_WIDTH || x >= UI_FRAME_TILE_WIDTH * 2
|
|
||||||
) {
|
|
||||||
c = border;
|
|
||||||
} else {
|
|
||||||
c = center;
|
|
||||||
}
|
|
||||||
UI_FRAME.pixels[y * UI_FRAME_TEXTURE_WIDTH_POW2 + x] = c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
errorChain(textureInit(
|
|
||||||
&UI_FRAME.texture,
|
|
||||||
UI_FRAME_TEXTURE_WIDTH_POW2, UI_FRAME_TEXTURE_HEIGHT_POW2,
|
|
||||||
TEXTURE_FORMAT_RGBA,
|
|
||||||
(texturedata_t){ .rgbaColors = UI_FRAME.pixels }
|
|
||||||
));
|
|
||||||
|
|
||||||
UI_FRAME.tileset.tileWidth = UI_FRAME_TILE_WIDTH;
|
|
||||||
UI_FRAME.tileset.tileHeight = UI_FRAME_TILE_HEIGHT;
|
|
||||||
UI_FRAME.tileset.columns = 3;
|
|
||||||
UI_FRAME.tileset.rows = 3;
|
|
||||||
UI_FRAME.tileset.tileCount = 9;
|
|
||||||
UI_FRAME.tileset.uv[0] =
|
|
||||||
(float_t)UI_FRAME_TILE_WIDTH / UI_FRAME_TEXTURE_WIDTH_POW2;
|
|
||||||
UI_FRAME.tileset.uv[1] =
|
|
||||||
(float_t)UI_FRAME_TILE_HEIGHT / UI_FRAME_TEXTURE_HEIGHT_POW2;
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiFrameDraw(
|
|
||||||
const float_t x,
|
|
||||||
const float_t y,
|
|
||||||
const float_t width,
|
|
||||||
const float_t height
|
|
||||||
) {
|
|
||||||
shadermaterial_t material = {
|
|
||||||
.unlit = {
|
|
||||||
.color = COLOR_WHITE,
|
|
||||||
.texture = &UI_FRAME.texture
|
|
||||||
}
|
|
||||||
};
|
|
||||||
spritebatchsprite_t sprites[9];
|
|
||||||
uiFrameBuildSprites(sprites, x, y, width, height);
|
|
||||||
return spriteBatchBuffer(sprites, 9, &SHADER_UNLIT, material);
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiFrameBuildSprites(
|
|
||||||
spritebatchsprite_t sprites[9],
|
|
||||||
const float_t x,
|
|
||||||
const float_t y,
|
|
||||||
const float_t width,
|
|
||||||
const float_t height
|
|
||||||
) {
|
|
||||||
float_t tileW = (float_t)UI_FRAME_BORDER_WIDTH;
|
|
||||||
float_t tileH = (float_t)UI_FRAME_BORDER_HEIGHT;
|
|
||||||
|
|
||||||
sprites[0] = spriteBatchSpriteTilesetPosition(
|
|
||||||
&UI_FRAME.tileset, 0, 0,
|
|
||||||
x, y, tileW, tileH
|
|
||||||
);
|
|
||||||
sprites[1] = spriteBatchSpriteTilesetPosition(
|
|
||||||
&UI_FRAME.tileset, 1, 0,
|
|
||||||
x + tileW, y, width - (tileW * 2.0f), tileH
|
|
||||||
);
|
|
||||||
sprites[2] = spriteBatchSpriteTilesetPosition(
|
|
||||||
&UI_FRAME.tileset, 2, 0,
|
|
||||||
x + width - tileW, y, tileW, tileH
|
|
||||||
);
|
|
||||||
sprites[3] = spriteBatchSpriteTilesetPosition(
|
|
||||||
&UI_FRAME.tileset, 0, 1,
|
|
||||||
x, y + tileH, tileW, height - (tileH * 2.0f)
|
|
||||||
);
|
|
||||||
sprites[4] = spriteBatchSpriteTilesetPosition(
|
|
||||||
&UI_FRAME.tileset, 1, 1,
|
|
||||||
x + tileW, y + tileH,
|
|
||||||
width - (tileW * 2.0f), height - (tileH * 2.0f)
|
|
||||||
);
|
|
||||||
sprites[5] = spriteBatchSpriteTilesetPosition(
|
|
||||||
&UI_FRAME.tileset, 2, 1,
|
|
||||||
x + width - tileW, y + tileH, tileW, height - (tileH * 2.0f)
|
|
||||||
);
|
|
||||||
sprites[6] = spriteBatchSpriteTilesetPosition(
|
|
||||||
&UI_FRAME.tileset, 0, 2,
|
|
||||||
x, y + height - tileH, tileW, tileH
|
|
||||||
);
|
|
||||||
sprites[7] = spriteBatchSpriteTilesetPosition(
|
|
||||||
&UI_FRAME.tileset, 1, 2,
|
|
||||||
x + tileW, y + height - tileH, width - (tileW * 2.0f), tileH
|
|
||||||
);
|
|
||||||
sprites[8] = spriteBatchSpriteTilesetPosition(
|
|
||||||
&UI_FRAME.tileset, 2, 2,
|
|
||||||
x + width - tileW, y + height - tileH, tileW, tileH
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiFrameDrawCached(
|
|
||||||
uiframecache_t *cache,
|
|
||||||
const float_t x,
|
|
||||||
const float_t y,
|
|
||||||
const float_t width,
|
|
||||||
const float_t height
|
|
||||||
) {
|
|
||||||
assertNotNull(cache, "Frame cache cannot be NULL");
|
|
||||||
|
|
||||||
if(
|
|
||||||
!cache->built ||
|
|
||||||
cache->lastX != x || cache->lastY != y ||
|
|
||||||
cache->lastWidth != width || cache->lastHeight != height
|
|
||||||
) {
|
|
||||||
uiFrameBuildSprites(cache->sprites, x, y, width, height);
|
|
||||||
cache->lastX = x;
|
|
||||||
cache->lastY = y;
|
|
||||||
cache->lastWidth = width;
|
|
||||||
cache->lastHeight = height;
|
|
||||||
cache->built = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
shadermaterial_t material = {
|
|
||||||
.unlit = {
|
|
||||||
.color = COLOR_WHITE,
|
|
||||||
.texture = &UI_FRAME.texture
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return spriteBatchBuffer(cache->sprites, 9, &SHADER_UNLIT, material);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiFrameDispose(void) {
|
|
||||||
return textureDispose(&UI_FRAME.texture);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "display/texture/texture.h"
|
|
||||||
#include "display/texture/tileset.h"
|
|
||||||
#include "display/spritebatch/spritebatchsprite.h"
|
|
||||||
|
|
||||||
#define UI_FRAME_BORDER_WIDTH 6
|
|
||||||
#define UI_FRAME_BORDER_HEIGHT 6
|
|
||||||
#define UI_FRAME_PADDING_X 2
|
|
||||||
#define UI_FRAME_PADDING_Y 2
|
|
||||||
#define UI_FRAME_START_X (UI_FRAME_BORDER_WIDTH + UI_FRAME_PADDING_X)
|
|
||||||
#define UI_FRAME_START_Y (UI_FRAME_BORDER_HEIGHT + UI_FRAME_PADDING_Y)
|
|
||||||
#define UI_FRAME_TILE_WIDTH 1
|
|
||||||
#define UI_FRAME_TILE_HEIGHT 1
|
|
||||||
#define UI_FRAME_TEXTURE_WIDTH (UI_FRAME_TILE_WIDTH * 3)
|
|
||||||
#define UI_FRAME_TEXTURE_HEIGHT (UI_FRAME_TILE_HEIGHT * 3)
|
|
||||||
#define UI_FRAME_TEXTURE_WIDTH_POW2 4
|
|
||||||
#define UI_FRAME_TEXTURE_HEIGHT_POW2 4
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
tileset_t tileset;
|
|
||||||
texture_t texture;
|
|
||||||
color_t pixels[UI_FRAME_BORDER_WIDTH * UI_FRAME_BORDER_HEIGHT];
|
|
||||||
} uiframe_t;
|
|
||||||
|
|
||||||
extern uiframe_t UI_FRAME;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the global UI_FRAME: builds the dummy texture and
|
|
||||||
* configures the tileset. Call once after displayInit().
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiFrameInit(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws UI_FRAME using 9-slice rendering from its 3x3 tileset.
|
|
||||||
* Pushes quads to the sprite batch without flushing.
|
|
||||||
*
|
|
||||||
* @param x Screen x position.
|
|
||||||
* @param y Screen y position.
|
|
||||||
* @param width Total width of the frame.
|
|
||||||
* @param height Total height of the frame.
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiFrameDraw(
|
|
||||||
const float_t x,
|
|
||||||
const float_t y,
|
|
||||||
const float_t width,
|
|
||||||
const float_t height
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the 9 sprites for a 9-slice frame at the given rect. Used
|
|
||||||
* internally by uiFrameDraw and uiFrameDrawCached -- call directly only
|
|
||||||
* if you need the raw sprites instead of buffering them.
|
|
||||||
*
|
|
||||||
* @param sprites Destination array of exactly 9 sprites.
|
|
||||||
* @param x Screen x position.
|
|
||||||
* @param y Screen y position.
|
|
||||||
* @param width Total width of the frame.
|
|
||||||
* @param height Total height of the frame.
|
|
||||||
*/
|
|
||||||
void uiFrameBuildSprites(
|
|
||||||
spritebatchsprite_t sprites[9],
|
|
||||||
const float_t x,
|
|
||||||
const float_t y,
|
|
||||||
const float_t width,
|
|
||||||
const float_t height
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A frame's cached 9-slice sprites, plus the rect they were built for.
|
|
||||||
* Owned by whichever widget/screen draws a frame repeatedly (dialogs,
|
|
||||||
* textboxes, settings panels) so uiFrameDrawCached can skip rebuilding
|
|
||||||
* the sprites when the rect hasn't moved since the last draw.
|
|
||||||
*/
|
|
||||||
typedef struct {
|
|
||||||
spritebatchsprite_t sprites[9];
|
|
||||||
bool_t built;
|
|
||||||
float_t lastX;
|
|
||||||
float_t lastY;
|
|
||||||
float_t lastWidth;
|
|
||||||
float_t lastHeight;
|
|
||||||
} uiframecache_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws a 9-slice frame using a caller-owned cache, only rebuilding the
|
|
||||||
* sprites (via uiFrameBuildSprites) when x/y/width/height differ from
|
|
||||||
* the last call -- unlike uiFrameDraw, which rebuilds unconditionally
|
|
||||||
* every time. Prefer this for frames redrawn every frame at a fixed or
|
|
||||||
* rarely-changing rect (dialogs, panels); use plain uiFrameDraw for
|
|
||||||
* genuinely one-off or per-frame-varying rects.
|
|
||||||
*
|
|
||||||
* @param cache Caller-owned cache, zero-initialized before first use.
|
|
||||||
* @param x Screen x position.
|
|
||||||
* @param y Screen y position.
|
|
||||||
* @param width Total width of the frame.
|
|
||||||
* @param height Total height of the frame.
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiFrameDrawCached(
|
|
||||||
uiframecache_t *cache,
|
|
||||||
const float_t x,
|
|
||||||
const float_t y,
|
|
||||||
const float_t width,
|
|
||||||
const float_t height
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disposes of the global UI_FRAME, releasing its GPU texture.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiFrameDispose(void);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# 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
|
|
||||||
uicrop.c
|
|
||||||
uifullbox.c
|
|
||||||
uiloading.c
|
|
||||||
)
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uifullbox.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "time/time.h"
|
|
||||||
#include "display/screen/screen.h"
|
|
||||||
#include "display/texture/texture.h"
|
|
||||||
#include "display/spritebatch/spritebatch.h"
|
|
||||||
#include "display/shader/shaderunlit.h"
|
|
||||||
|
|
||||||
uifullbox_t UI_FULLBOX_UNDER;
|
|
||||||
uifullbox_t UI_FULLBOX_OVER;
|
|
||||||
|
|
||||||
void uiFullboxInit(uifullbox_t *fullbox) {
|
|
||||||
assertNotNull(fullbox, "fullbox must not be NULL");
|
|
||||||
memoryZero(fullbox, sizeof(uifullbox_t));
|
|
||||||
eventInit(
|
|
||||||
&fullbox->onTransitionEnd,
|
|
||||||
fullbox->onTransitionEndCallbacks,
|
|
||||||
fullbox->onTransitionEndUsers,
|
|
||||||
4
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiFullboxUpdate(uifullbox_t *fullbox, float_t delta) {
|
|
||||||
assertNotNull(fullbox, "fullbox must not be NULL");
|
|
||||||
if(fullbox->duration <= 0.0f || fullbox->time >= fullbox->duration) return;
|
|
||||||
|
|
||||||
fullbox->time += delta;
|
|
||||||
if(fullbox->time >= fullbox->duration) {
|
|
||||||
fullbox->time = fullbox->duration;
|
|
||||||
eventInvoke(&fullbox->onTransitionEnd, fullbox);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static color_t uiFullboxGetColor(const uifullbox_t *fullbox) {
|
|
||||||
if(fullbox->duration <= 0.0f || fullbox->time >= fullbox->duration) {
|
|
||||||
return fullbox->toColor;
|
|
||||||
}
|
|
||||||
float_t t = easingApply(fullbox->easing, fullbox->time / fullbox->duration);
|
|
||||||
return color4b(
|
|
||||||
(uint8_t)((float_t)fullbox->fromColor.r +
|
|
||||||
((float_t)fullbox->toColor.r - (float_t)fullbox->fromColor.r) * t),
|
|
||||||
(uint8_t)((float_t)fullbox->fromColor.g +
|
|
||||||
((float_t)fullbox->toColor.g - (float_t)fullbox->fromColor.g) * t),
|
|
||||||
(uint8_t)((float_t)fullbox->fromColor.b +
|
|
||||||
((float_t)fullbox->toColor.b - (float_t)fullbox->fromColor.b) * t),
|
|
||||||
(uint8_t)((float_t)fullbox->fromColor.a +
|
|
||||||
((float_t)fullbox->toColor.a - (float_t)fullbox->fromColor.a) * t)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiFullboxDraw(uifullbox_t *fullbox) {
|
|
||||||
assertNotNull(fullbox, "fullbox must not be NULL");
|
|
||||||
|
|
||||||
color_t color = uiFullboxGetColor(fullbox);
|
|
||||||
if(color.a == 0) errorOk();
|
|
||||||
|
|
||||||
spritebatchsprite_t sprite = {
|
|
||||||
.min = { 0.0f, 0.0f, 0.0f },
|
|
||||||
.max = { (float_t)SCREEN.width, (float_t)SCREEN.height, 0.0f },
|
|
||||||
.uvMin = { 0.0f, 0.0f },
|
|
||||||
.uvMax = { 1.0f, 1.0f }
|
|
||||||
};
|
|
||||||
shadermaterial_t material = {
|
|
||||||
.unlit = {
|
|
||||||
.color = color,
|
|
||||||
.texture = &TEXTURE_WHITE
|
|
||||||
}
|
|
||||||
};
|
|
||||||
errorChain(spriteBatchBuffer(&sprite, 1, &SHADER_UNLIT, material));
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiFullboxTransition(
|
|
||||||
uifullbox_t *fullbox,
|
|
||||||
color_t from,
|
|
||||||
color_t to,
|
|
||||||
float_t duration,
|
|
||||||
easingtype_t easing
|
|
||||||
) {
|
|
||||||
assertNotNull(fullbox, "fullbox must not be NULL");
|
|
||||||
fullbox->fromColor = from;
|
|
||||||
fullbox->toColor = to;
|
|
||||||
fullbox->duration = duration;
|
|
||||||
fullbox->time = 0.0f;
|
|
||||||
fullbox->easing = easing;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiFullboxUnderInit(void) {
|
|
||||||
uiFullboxInit(&UI_FULLBOX_UNDER);
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiFullboxUnderUpdate(void) {
|
|
||||||
uiFullboxUpdate(&UI_FULLBOX_UNDER, TIME.delta);
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiFullboxUnderDraw(void) {
|
|
||||||
return uiFullboxDraw(&UI_FULLBOX_UNDER);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiFullboxOverInit(void) {
|
|
||||||
uiFullboxInit(&UI_FULLBOX_OVER);
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiFullboxOverUpdate(void) {
|
|
||||||
uiFullboxUpdate(&UI_FULLBOX_OVER, TIME.delta);
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiFullboxOverDraw(void) {
|
|
||||||
return uiFullboxDraw(&UI_FULLBOX_OVER);
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "display/color.h"
|
|
||||||
#include "animation/easing.h"
|
|
||||||
#include "event/event.h"
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
color_t fromColor;
|
|
||||||
color_t toColor;
|
|
||||||
float_t duration;
|
|
||||||
float_t time;
|
|
||||||
easingtype_t easing;
|
|
||||||
eventcallback_t onTransitionEndCallbacks[4];
|
|
||||||
void *onTransitionEndUsers[4];
|
|
||||||
event_t onTransitionEnd;
|
|
||||||
} uifullbox_t;
|
|
||||||
|
|
||||||
extern uifullbox_t UI_FULLBOX_UNDER;
|
|
||||||
extern uifullbox_t UI_FULLBOX_OVER;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes a fullbox, zeroing all fields.
|
|
||||||
*
|
|
||||||
* @param fullbox The fullbox to initialize.
|
|
||||||
*/
|
|
||||||
void uiFullboxInit(uifullbox_t *fullbox);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Advances the fullbox transition. Fires onTransitionEnd once when the
|
|
||||||
* transition completes. Safe to call when no transition is running.
|
|
||||||
*
|
|
||||||
* @param fullbox The fullbox to update.
|
|
||||||
* @param delta Seconds elapsed since last update.
|
|
||||||
*/
|
|
||||||
void uiFullboxUpdate(uifullbox_t *fullbox, float_t delta);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the fullbox. Skipped entirely when the current alpha is zero.
|
|
||||||
*
|
|
||||||
* @param fullbox The fullbox to draw.
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiFullboxDraw(uifullbox_t *fullbox);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Begins a color transition.
|
|
||||||
*
|
|
||||||
* @param fullbox The fullbox to transition.
|
|
||||||
* @param from Start color.
|
|
||||||
* @param to End color.
|
|
||||||
* @param duration Transition duration in seconds.
|
|
||||||
* @param easing Easing function to apply.
|
|
||||||
*/
|
|
||||||
void uiFullboxTransition(
|
|
||||||
uifullbox_t *fullbox,
|
|
||||||
color_t from,
|
|
||||||
color_t to,
|
|
||||||
float_t duration,
|
|
||||||
easingtype_t easing
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Init function for UI_FULLBOX_UNDER (registered in UI_ELEMENTS).
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiFullboxUnderInit(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update function for UI_FULLBOX_UNDER (registered in UI_ELEMENTS).
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiFullboxUnderUpdate(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draw function for UI_FULLBOX_UNDER (registered in UI_ELEMENTS).
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiFullboxUnderDraw(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Init function for UI_FULLBOX_OVER (registered in UI_ELEMENTS).
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiFullboxOverInit(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update function for UI_FULLBOX_OVER (registered in UI_ELEMENTS).
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiFullboxOverUpdate(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draw function for UI_FULLBOX_OVER (registered in UI_ELEMENTS).
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiFullboxOverDraw(void);
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uiloading.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "time/time.h"
|
|
||||||
#include "display/text/text.h"
|
|
||||||
#include "display/screen/screen.h"
|
|
||||||
#include "uifullbox.h"
|
|
||||||
|
|
||||||
#define UI_LOADING_TEXT "loading"
|
|
||||||
|
|
||||||
uiloading_t UI_LOADING;
|
|
||||||
|
|
||||||
errorret_t uiLoadingInit(void) {
|
|
||||||
memoryZero(&UI_LOADING, sizeof(uiloading_t));
|
|
||||||
uiLabelInit(&UI_LOADING.textLabel, &FONT_DEFAULT);
|
|
||||||
uiLabelSetText(&UI_LOADING.textLabel, UI_LOADING_TEXT);
|
|
||||||
eventInit(
|
|
||||||
&UI_LOADING.onTransitionEnd,
|
|
||||||
UI_LOADING.onTransitionEndCallbacks,
|
|
||||||
UI_LOADING.onTransitionEndUsers,
|
|
||||||
4
|
|
||||||
);
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiLoadingUpdate(void) {
|
|
||||||
if(UI_LOADING.duration > 0.0f && UI_LOADING.time < UI_LOADING.duration) {
|
|
||||||
UI_LOADING.time += TIME.delta;
|
|
||||||
if(UI_LOADING.time >= UI_LOADING.duration) {
|
|
||||||
UI_LOADING.time = UI_LOADING.duration;
|
|
||||||
eventInvoke(&UI_LOADING.onTransitionEnd, &UI_LOADING);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiLoadingDraw(void) {
|
|
||||||
float_t alpha;
|
|
||||||
if(UI_LOADING.duration <= 0.0f || UI_LOADING.time >= UI_LOADING.duration) {
|
|
||||||
alpha = UI_LOADING.toAlpha;
|
|
||||||
} else {
|
|
||||||
float_t t = UI_LOADING.time / UI_LOADING.duration;
|
|
||||||
alpha = UI_LOADING.fromAlpha +
|
|
||||||
(UI_LOADING.toAlpha - UI_LOADING.fromAlpha) * t;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(alpha <= 0.0f) errorOk();
|
|
||||||
|
|
||||||
int32_t textW, textH;
|
|
||||||
uiLabelGetSize(&UI_LOADING.textLabel, &textW, &textH);
|
|
||||||
|
|
||||||
float_t x = (float_t)(SCREEN.scanX + SCREEN.scanWidth) -
|
|
||||||
(float_t)textW - UI_LOADING_MARGIN;
|
|
||||||
float_t y = (float_t)(SCREEN.scanY + SCREEN.scanHeight) -
|
|
||||||
(float_t)textH - UI_LOADING_MARGIN;
|
|
||||||
|
|
||||||
color_t color = COLOR_WHITE;
|
|
||||||
color.a = (uint8_t)(alpha * 255.0f);
|
|
||||||
uiLabelSetColor(&UI_LOADING.textLabel, color);
|
|
||||||
|
|
||||||
errorChain(uiLabelDraw(&UI_LOADING.textLabel, x, y));
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void uiLoadingTransition(
|
|
||||||
const float_t from,
|
|
||||||
const float_t to,
|
|
||||||
const eventcallback_t callback,
|
|
||||||
void *user
|
|
||||||
) {
|
|
||||||
UI_LOADING.fromAlpha = from;
|
|
||||||
UI_LOADING.toAlpha = to;
|
|
||||||
UI_LOADING.duration = UI_LOADING_FADE_DURATION;
|
|
||||||
UI_LOADING.time = 0.0f;
|
|
||||||
eventInit(
|
|
||||||
&UI_LOADING.onTransitionEnd,
|
|
||||||
UI_LOADING.onTransitionEndCallbacks,
|
|
||||||
UI_LOADING.onTransitionEndUsers,
|
|
||||||
4
|
|
||||||
);
|
|
||||||
if(callback) eventSubscribe(&UI_LOADING.onTransitionEnd, callback, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiLoadingShow(eventcallback_t callback, void *user) {
|
|
||||||
uiLoadingTransition(0.0f, 1.0f, callback, user);
|
|
||||||
uiFullboxTransition(
|
|
||||||
&UI_FULLBOX_OVER,
|
|
||||||
COLOR_TRANSPARENT_BLACK,
|
|
||||||
COLOR_BLACK,
|
|
||||||
UI_LOADING_FADE_DURATION,
|
|
||||||
EASING_LINEAR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiLoadingHide(eventcallback_t callback, void *user) {
|
|
||||||
uiLoadingTransition(1.0f, 0.0f, callback, user);
|
|
||||||
uiFullboxTransition(
|
|
||||||
&UI_FULLBOX_OVER,
|
|
||||||
COLOR_BLACK,
|
|
||||||
COLOR_TRANSPARENT_BLACK,
|
|
||||||
UI_LOADING_FADE_DURATION,
|
|
||||||
EASING_LINEAR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "event/event.h"
|
|
||||||
#include "ui/widget/uilabel.h"
|
|
||||||
|
|
||||||
#define UI_LOADING_FADE_DURATION 0.5f
|
|
||||||
#define UI_LOADING_MARGIN 8.0f
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
float_t fromAlpha;
|
|
||||||
float_t toAlpha;
|
|
||||||
float_t duration;
|
|
||||||
float_t time;
|
|
||||||
eventcallback_t onTransitionEndCallbacks[4];
|
|
||||||
void *onTransitionEndUsers[4];
|
|
||||||
event_t onTransitionEnd;
|
|
||||||
uilabel_t textLabel;
|
|
||||||
} uiloading_t;
|
|
||||||
|
|
||||||
extern uiloading_t UI_LOADING;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the loading indicator.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiLoadingInit(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Advances the loading indicator fade transition. Fires onTransitionEnd once
|
|
||||||
* when the transition completes.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiLoadingUpdate(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws the loading indicator. No-op when fully transparent.
|
|
||||||
*
|
|
||||||
* @return Error state.
|
|
||||||
*/
|
|
||||||
errorret_t uiLoadingDraw(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fades the loading indicator in. Invokes callback when fully visible.
|
|
||||||
*
|
|
||||||
* @param callback Called when the fade-in completes. May be NULL.
|
|
||||||
* @param user Forwarded to the callback unchanged.
|
|
||||||
*/
|
|
||||||
void uiLoadingShow(eventcallback_t callback, void *user);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fades the loading indicator out. Invokes callback when fully hidden.
|
|
||||||
*
|
|
||||||
* @param callback Called when the fade-out completes. May be NULL.
|
|
||||||
* @param user Forwarded to the callback unchanged.
|
|
||||||
*/
|
|
||||||
void uiLoadingHide(eventcallback_t callback, void *user);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# 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
|
|
||||||
uitransition.c
|
|
||||||
uitransitionfade.c
|
|
||||||
)
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uitransition.h"
|
|
||||||
#include "uitransitionfade.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "time/time.h"
|
|
||||||
#include "util/math.h"
|
|
||||||
|
|
||||||
uitransition_t UI_TRANSITION;
|
|
||||||
|
|
||||||
uitransitiondrawfunc_t UI_TRANSITION_TYPE_DRAW[UI_TRANSITION_TYPE_COUNT] = {
|
|
||||||
[UI_TRANSITION_TYPE_FADE] = uiTransitionFadeDraw
|
|
||||||
};
|
|
||||||
|
|
||||||
errorret_t uiTransitionInit(void) {
|
|
||||||
memoryZero(&UI_TRANSITION, sizeof(uitransition_t));
|
|
||||||
|
|
||||||
// uiTransitionStart((uitransitionstartparams_t){
|
|
||||||
// .duration = 5.0f,
|
|
||||||
// .type = UI_TRANSITION_TYPE_FADE,
|
|
||||||
// .params = {
|
|
||||||
// .fade = {
|
|
||||||
// .easing = EASING_OUT_QUART,
|
|
||||||
// .fromColor = COLOR_TRANSPARENT_BLACK,
|
|
||||||
// .toColor = COLOR_BLACK
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// .finished = NULL,
|
|
||||||
// .user = NULL
|
|
||||||
// });
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiTransitionUpdate(void) {
|
|
||||||
// Update time.
|
|
||||||
float_t newTime = UI_TRANSITION.data.time + TIME.delta;
|
|
||||||
|
|
||||||
UI_TRANSITION.data.time = mathClamp(
|
|
||||||
newTime,
|
|
||||||
0.0f,
|
|
||||||
UI_TRANSITION.data.duration
|
|
||||||
);
|
|
||||||
UI_TRANSITION.data.t = (
|
|
||||||
(UI_TRANSITION.data.duration <= 0.0f) ?
|
|
||||||
1.0f :
|
|
||||||
(UI_TRANSITION.data.time / UI_TRANSITION.data.duration)
|
|
||||||
);
|
|
||||||
|
|
||||||
if(UI_TRANSITION.data.t >= 1.0f && UI_TRANSITION.finished) {
|
|
||||||
UI_TRANSITION.finished(UI_TRANSITION.user);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiTransitionDraw(void) {
|
|
||||||
uitransitiondrawfunc_t draw = UI_TRANSITION_TYPE_DRAW[UI_TRANSITION.type];
|
|
||||||
if(!draw) errorOk();
|
|
||||||
return draw(&UI_TRANSITION.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiTransitionStart(const uitransitionstartparams_t info) {
|
|
||||||
assertTrue(info.type > UI_TRANSITION_TYPE_NULL, "Invalid type");
|
|
||||||
assertTrue(info.type < UI_TRANSITION_TYPE_COUNT, "Invalid type");
|
|
||||||
assertTrue(info.duration >= 0.0f, "Duration must be non-negative");
|
|
||||||
|
|
||||||
memoryZero(&UI_TRANSITION, sizeof(uitransition_t));
|
|
||||||
|
|
||||||
UI_TRANSITION.data.duration = info.duration;
|
|
||||||
UI_TRANSITION.type = info.type;
|
|
||||||
UI_TRANSITION.data.params = info.params;
|
|
||||||
UI_TRANSITION.finished = info.finished;
|
|
||||||
UI_TRANSITION.user = info.user;
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "display/color.h"
|
|
||||||
#include "animation/easing.h"
|
|
||||||
#include "uitransitiondata.h"
|
|
||||||
|
|
||||||
typedef void (*uitransitioncallback_t)(void *user);
|
|
||||||
|
|
||||||
typedef enum {
|
|
||||||
UI_TRANSITION_TYPE_NULL,
|
|
||||||
|
|
||||||
UI_TRANSITION_TYPE_FADE,
|
|
||||||
|
|
||||||
UI_TRANSITION_TYPE_COUNT
|
|
||||||
} uitransitiontype_t;
|
|
||||||
|
|
||||||
extern uitransitiondrawfunc_t UI_TRANSITION_TYPE_DRAW[UI_TRANSITION_TYPE_COUNT];
|
|
||||||
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uitransitiondata_t data;
|
|
||||||
uitransitiontype_t type;
|
|
||||||
uitransitioncallback_t finished;
|
|
||||||
void *user;
|
|
||||||
} uitransition_t;
|
|
||||||
|
|
||||||
extern uitransition_t UI_TRANSITION;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the transition overlay, zeroing all fields and wiring the
|
|
||||||
* onTransitionEnd event.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiTransitionInit(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Advances the transition. Fires onTransitionEnd once when it completes.
|
|
||||||
* Safe to call when no transition is running.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiTransitionUpdate(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the transition overlay. Skipped entirely when the current alpha
|
|
||||||
* is zero.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiTransitionDraw(void);
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
float_t duration;
|
|
||||||
uitransitiontype_t type;
|
|
||||||
uitransitionparams_t params;
|
|
||||||
uitransitioncallback_t finished;
|
|
||||||
void *user;
|
|
||||||
} uitransitionstartparams_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts a transition described by an info struct.
|
|
||||||
*
|
|
||||||
* @param info Transition parameters.
|
|
||||||
*/
|
|
||||||
void uiTransitionStart(const uitransitionstartparams_t info);
|
|
||||||
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "uitransitionfade.h"
|
|
||||||
|
|
||||||
typedef union uitransitionparams_u {
|
|
||||||
uitransitionfade_t fade;
|
|
||||||
} uitransitionparams_t;
|
|
||||||
|
|
||||||
typedef struct uitransitiondata_s {
|
|
||||||
float_t time;
|
|
||||||
float_t duration;
|
|
||||||
float_t t;
|
|
||||||
uitransitionparams_t params;
|
|
||||||
} uitransitiondata_t;
|
|
||||||
|
|
||||||
typedef errorret_t (*uitransitiondrawfunc_t)(const uitransitiondata_t *data);
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uitransition.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "display/screen/screen.h"
|
|
||||||
#include "display/texture/texture.h"
|
|
||||||
#include "display/spritebatch/spritebatch.h"
|
|
||||||
#include "display/shader/shaderunlit.h"
|
|
||||||
|
|
||||||
errorret_t uiTransitionFadeDraw(const uitransitiondata_t *data) {
|
|
||||||
assertNotNull(data, "data must not be NULL");
|
|
||||||
|
|
||||||
float_t e = easingApply(data->params.fade.easing, data->t);
|
|
||||||
|
|
||||||
float_t r = (float_t)(
|
|
||||||
data->params.fade.toColor.r - data->params.fade.fromColor.r
|
|
||||||
) * e;
|
|
||||||
float_t g = (float_t)(
|
|
||||||
data->params.fade.toColor.g - data->params.fade.fromColor.g
|
|
||||||
) * e;
|
|
||||||
float_t b = (float_t)(
|
|
||||||
data->params.fade.toColor.b - data->params.fade.fromColor.b
|
|
||||||
) * e;
|
|
||||||
float_t a = (float_t)(
|
|
||||||
data->params.fade.toColor.a - data->params.fade.fromColor.a
|
|
||||||
) * e;
|
|
||||||
|
|
||||||
color_t color = color4b(
|
|
||||||
data->params.fade.fromColor.r + (uint8_t)r,
|
|
||||||
data->params.fade.fromColor.g + (uint8_t)g,
|
|
||||||
data->params.fade.fromColor.b + (uint8_t)b,
|
|
||||||
data->params.fade.fromColor.a + (uint8_t)a
|
|
||||||
);
|
|
||||||
|
|
||||||
if(color.a == 0) errorOk();
|
|
||||||
|
|
||||||
spritebatchsprite_t sprite = {
|
|
||||||
.min = { 0.0f, 0.0f, 0.0f },
|
|
||||||
.max = { (float_t)SCREEN.width, (float_t)SCREEN.height, 0.0f },
|
|
||||||
.uvMin = { 0.0f, 0.0f },
|
|
||||||
.uvMax = { 1.0f, 1.0f }
|
|
||||||
};
|
|
||||||
shadermaterial_t material = {
|
|
||||||
.unlit = {
|
|
||||||
.color = color,
|
|
||||||
.texture = NULL
|
|
||||||
}
|
|
||||||
};
|
|
||||||
errorChain(spriteBatchBuffer(&sprite, 1, &SHADER_UNLIT, material));
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "display/color.h"
|
|
||||||
#include "animation/easing.h"
|
|
||||||
|
|
||||||
typedef struct uitransitiondata_s uitransitiondata_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
color_t fromColor;
|
|
||||||
color_t toColor;
|
|
||||||
easingtype_t easing;
|
|
||||||
} uitransitionfade_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a full-screen color overlay interpolated between fade->fromColor
|
|
||||||
* and fade->toColor using fade->easing. t is the raw normalized progress
|
|
||||||
* in [0, 1] before easing is applied. No-op when the resulting alpha is zero.
|
|
||||||
*
|
|
||||||
* @param fade The fade parameters.
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiTransitionFadeDraw(const uitransitiondata_t *fade);
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "ui.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "display/spritebatch/spritebatch.h"
|
|
||||||
#include "display/screen/screen.h"
|
|
||||||
#include "ui/uielement.h"
|
|
||||||
#include "ui/focus/uifocus.h"
|
|
||||||
|
|
||||||
ui_t UI;
|
|
||||||
|
|
||||||
errorret_t uiInit(void) {
|
|
||||||
memoryZero(&UI, sizeof(ui_t));
|
|
||||||
uiFocusInit();
|
|
||||||
uiElementsSort();
|
|
||||||
|
|
||||||
uielement_t *element = &UI_ELEMENTS[0];
|
|
||||||
while(!uiElementIsNull(element)) {
|
|
||||||
errorChain(uiElementInit(element));
|
|
||||||
element++;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiUpdate(void) {
|
|
||||||
uiFocusUpdate();
|
|
||||||
|
|
||||||
uielement_t *element = &UI_ELEMENTS[0];
|
|
||||||
while(!uiElementIsNull(element)) {
|
|
||||||
errorChain(uiElementUpdate(element));
|
|
||||||
element++;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiRender(void) {
|
|
||||||
const uielement_t *element = &UI_ELEMENTS[0];
|
|
||||||
while(!uiElementIsNull(element)) {
|
|
||||||
errorChain(uiElementDraw(element));
|
|
||||||
element++;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorChain(spriteBatchFlush());
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiDispose(void) {
|
|
||||||
uielement_t *element = &UI_ELEMENTS[0];
|
|
||||||
while(!uiElementIsNull(element)) {
|
|
||||||
errorChain(uiElementDispose(element));
|
|
||||||
element++;
|
|
||||||
}
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
void *nothing;
|
|
||||||
} ui_t;
|
|
||||||
|
|
||||||
extern ui_t UI;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the UI system.
|
|
||||||
*/
|
|
||||||
errorret_t uiInit(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the UI system.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiUpdate(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the UI system.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiRender(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disposes of the UI system.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiDispose(void);
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uielement.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/sort.h"
|
|
||||||
#include "ui/frame/uiframe.h"
|
|
||||||
#include "ui/debug/uifps.h"
|
|
||||||
#include "engine/engine.h"
|
|
||||||
#include "ui/overlay/uifullbox.h"
|
|
||||||
#include "ui/overlay/uiloading.h"
|
|
||||||
#include "ui/overlay/uicrop.h"
|
|
||||||
#include "ui/transition/uitransition.h"
|
|
||||||
#include "ui/debug/uiconsole.h"
|
|
||||||
#include "ui/frame/uiconfirm.h"
|
|
||||||
|
|
||||||
// Priming pass: X does nothing here, so this only exists to process any
|
|
||||||
// #include directives nested in uielementlist.h/ui/uielementgame.h at
|
|
||||||
// file scope (each such header's own #pragma once makes it a no-op on
|
|
||||||
// the real pass below, which happens inside UI_ELEMENTS[]'s braces,
|
|
||||||
// where a raw #include of a declaration would be invalid).
|
|
||||||
#define X(initFn, updateFn, drawFn, disposeFn, order) // do nothing
|
|
||||||
#include "uielementlist.h"
|
|
||||||
#undef X
|
|
||||||
|
|
||||||
uielement_t UI_ELEMENTS[] = {
|
|
||||||
#define X(initFn, updateFn, drawFn, disposeFn, elementOrder) \
|
|
||||||
{ \
|
|
||||||
.init = initFn, .update = updateFn, .draw = drawFn, \
|
|
||||||
.dispose = disposeFn, .order = elementOrder \
|
|
||||||
},
|
|
||||||
#include "uielementlist.h"
|
|
||||||
#undef X
|
|
||||||
|
|
||||||
{ 0 } // Null terminator
|
|
||||||
};
|
|
||||||
|
|
||||||
bool_t uiElementIsNull(const uielement_t *element) {
|
|
||||||
return element->init == NULL &&
|
|
||||||
element->update == NULL &&
|
|
||||||
element->draw == NULL &&
|
|
||||||
element->dispose == NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
int_t uiElementCompareOrder(const void *a, const void *b) {
|
|
||||||
const uielement_t *elementA = (const uielement_t *)a;
|
|
||||||
const uielement_t *elementB = (const uielement_t *)b;
|
|
||||||
if(elementA->order < elementB->order) return -1;
|
|
||||||
if(elementA->order > elementB->order) return 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiElementsSort(void) {
|
|
||||||
// The trailing null terminator is always the last static entry -- sort
|
|
||||||
// everything before it, and leave it in place.
|
|
||||||
const size_t count = (sizeof(UI_ELEMENTS) / sizeof(UI_ELEMENTS[0])) - 1;
|
|
||||||
sortBubble(UI_ELEMENTS, count, sizeof(uielement_t), uiElementCompareOrder);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiElementInit(uielement_t *element) {
|
|
||||||
assertNotNull(element, "element must not be NULL");
|
|
||||||
if(element->init != NULL) errorChain(element->init());
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiElementUpdate(uielement_t *element) {
|
|
||||||
assertNotNull(element, "element must not be NULL");
|
|
||||||
if(element->update != NULL) errorChain(element->update());
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiElementDraw(const uielement_t *element) {
|
|
||||||
assertNotNull(element, "element must not be NULL");
|
|
||||||
if(element->draw != NULL) errorChain(element->draw());
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiElementDispose(uielement_t *element) {
|
|
||||||
assertNotNull(element, "element must not be NULL");
|
|
||||||
if(element->dispose != NULL) errorChain(element->dispose());
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
|
|
||||||
// Built-in order tiers. Lower values update/render first; ties preserve
|
|
||||||
// their relative UI_ELEMENTS declaration order (uiElementsSort uses a
|
|
||||||
// stable sort). Game-specific elements can use any int32_t value -- these
|
|
||||||
// are just the ones the engine itself relies on.
|
|
||||||
#define UI_ELEMENT_ORDER_DEFAULT 0
|
|
||||||
#define UI_ELEMENT_ORDER_DEBUG 1000
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
errorret_t (*init)();
|
|
||||||
errorret_t (*update)();
|
|
||||||
errorret_t (*draw)();
|
|
||||||
errorret_t (*dispose)();
|
|
||||||
int32_t order;
|
|
||||||
} uielement_t;
|
|
||||||
|
|
||||||
extern uielement_t UI_ELEMENTS[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true when all four callbacks on the element are NULL,
|
|
||||||
* which marks the end of the UI_ELEMENTS array.
|
|
||||||
*
|
|
||||||
* @param element The element to test.
|
|
||||||
* @returns True if the element is the null terminator.
|
|
||||||
*/
|
|
||||||
bool_t uiElementIsNull(const uielement_t *element);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compares two elements by their .order field, ascending. Matches
|
|
||||||
* sortcompare_t, for use with the project's sort utilities.
|
|
||||||
*
|
|
||||||
* @param a First uielement_t to compare.
|
|
||||||
* @param b Second uielement_t to compare.
|
|
||||||
* @return Negative if a < b, zero if a == b, positive if a > b.
|
|
||||||
*/
|
|
||||||
int_t uiElementCompareOrder(const void *a, const void *b);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stably sorts UI_ELEMENTS in place by .order, ascending. The trailing
|
|
||||||
* null terminator is never moved. Called once by uiInit -- element order
|
|
||||||
* is static after that, so there's no need to re-sort every frame.
|
|
||||||
*/
|
|
||||||
void uiElementsSort(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes a UI element, invoking its init callback if set.
|
|
||||||
*
|
|
||||||
* @param element The element to initialize.
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiElementInit(uielement_t *element);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates a UI element, calling its update callback if set.
|
|
||||||
*
|
|
||||||
* @param element The element to update.
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiElementUpdate(uielement_t *element);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws a UI element, calling its draw callback if set.
|
|
||||||
*
|
|
||||||
* @param element The element to render.
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiElementDraw(const uielement_t *element);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disposes of a UI element, invoking its dispose callback if set.
|
|
||||||
*
|
|
||||||
* @param element The element to dispose.
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiElementDispose(uielement_t *element);
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
// X(init, update, draw, dispose, order) -- pass NULL for any callback the
|
|
||||||
// element doesn't need. order controls update/draw sequence (lower first,
|
|
||||||
// see UI_ELEMENT_ORDER_* in uielement.h); ties preserve declaration order.
|
|
||||||
// See uielement.c for how this expands.
|
|
||||||
|
|
||||||
X(uiFrameInit, NULL, NULL, uiFrameDispose, UI_ELEMENT_ORDER_DEFAULT)
|
|
||||||
|
|
||||||
// Fullbox under: above scene, below system UI.
|
|
||||||
X(
|
|
||||||
uiFullboxUnderInit, uiFullboxUnderUpdate, uiFullboxUnderDraw, NULL,
|
|
||||||
UI_ELEMENT_ORDER_DEFAULT
|
|
||||||
)
|
|
||||||
|
|
||||||
// Text stuffs
|
|
||||||
X(uiConfirmInit, NULL, uiConfirmDraw, uiConfirmDispose, UI_ELEMENT_ORDER_DEFAULT)
|
|
||||||
|
|
||||||
X(
|
|
||||||
uiTransitionInit, uiTransitionUpdate, uiTransitionDraw, NULL,
|
|
||||||
UI_ELEMENT_ORDER_DEFAULT
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fullbox over: above absolutely everything (except debug).
|
|
||||||
X(
|
|
||||||
uiFullboxOverInit, uiFullboxOverUpdate, uiFullboxOverDraw, NULL,
|
|
||||||
UI_ELEMENT_ORDER_DEFAULT
|
|
||||||
)
|
|
||||||
|
|
||||||
X(
|
|
||||||
uiLoadingInit, uiLoadingUpdate, uiLoadingDraw, NULL,
|
|
||||||
UI_ELEMENT_ORDER_DEFAULT
|
|
||||||
)
|
|
||||||
|
|
||||||
X(uiCropInit, NULL, uiCropDraw, NULL, UI_ELEMENT_ORDER_DEFAULT)
|
|
||||||
|
|
||||||
// Debug items -- always last.
|
|
||||||
X(NULL, NULL, uiConsoleDraw, uiConsoleDispose, UI_ELEMENT_ORDER_DEBUG)
|
|
||||||
X(uiFPSInit, NULL, uiFPSDraw, NULL, UI_ELEMENT_ORDER_DEBUG)
|
|
||||||
|
|
||||||
// Game-specific UI elements (duskrpg)
|
|
||||||
#include "ui/uielementgame.h"
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Copyright (c) 2026 Dominic Masters
|
|
||||||
#
|
|
||||||
# This software is released under the MIT License.
|
|
||||||
# https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
|
||||||
PUBLIC
|
|
||||||
uibutton.c
|
|
||||||
uicheckbox.c
|
|
||||||
uitab.c
|
|
||||||
uislider.c
|
|
||||||
uidropdown.c
|
|
||||||
uiscrolling.c
|
|
||||||
uimenu.c
|
|
||||||
uilabel.c
|
|
||||||
uiwidgetlabel.c
|
|
||||||
)
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uilabel.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "util/string.h"
|
|
||||||
#include "display/text/text.h"
|
|
||||||
|
|
||||||
void uiLabelInit(uilabel_t *label, font_t *font) {
|
|
||||||
assertNotNull(label, "Label cannot be NULL");
|
|
||||||
assertNotNull(font, "Font cannot be NULL");
|
|
||||||
|
|
||||||
memoryZero(label, sizeof(uilabel_t));
|
|
||||||
label->font = font;
|
|
||||||
label->color = COLOR_WHITE;
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiLabelSetText(uilabel_t *label, const char_t *text) {
|
|
||||||
assertNotNull(label, "Label cannot be NULL");
|
|
||||||
assertNotNull(text, "Text cannot be NULL");
|
|
||||||
assertStrLenMax(text, UI_LABEL_TEXT_MAX, "Label text too long");
|
|
||||||
|
|
||||||
stringCopy(label->text, text, UI_LABEL_TEXT_MAX);
|
|
||||||
label->spriteCount = textBuildSpriteCache(
|
|
||||||
label->text, label->font, label->sprites, UI_LABEL_SPRITE_COUNT_MAX,
|
|
||||||
&label->width, &label->height
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiLabelSetColor(uilabel_t *label, const color_t color) {
|
|
||||||
assertNotNull(label, "Label cannot be NULL");
|
|
||||||
label->color = color;
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiLabelGetSize(
|
|
||||||
const uilabel_t *label,
|
|
||||||
int32_t *outWidth,
|
|
||||||
int32_t *outHeight
|
|
||||||
) {
|
|
||||||
assertNotNull(label, "Label cannot be NULL");
|
|
||||||
assertNotNull(outWidth, "Output width cannot be NULL");
|
|
||||||
assertNotNull(outHeight, "Output height cannot be NULL");
|
|
||||||
*outWidth = label->width;
|
|
||||||
*outHeight = label->height;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiLabelDraw(
|
|
||||||
const uilabel_t *label,
|
|
||||||
const float_t x,
|
|
||||||
const float_t y
|
|
||||||
) {
|
|
||||||
assertNotNull(label, "Label cannot be NULL");
|
|
||||||
if(label->spriteCount == 0) errorOk();
|
|
||||||
|
|
||||||
// Cached sprites are relative to (0,0); textDrawSpriteCache translates
|
|
||||||
// into the requested screen position here instead of in
|
|
||||||
// uiLabelSetText, so a label can be repositioned every frame without
|
|
||||||
// rebuilding the (much more expensive) glyph/UV cache.
|
|
||||||
spritebatchsprite_t scratch[UI_LABEL_SPRITE_COUNT_MAX];
|
|
||||||
errorChain(textDrawSpriteCache(
|
|
||||||
label->sprites, label->spriteCount, scratch, x, y, label->color,
|
|
||||||
label->font->texture
|
|
||||||
));
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "display/text/font.h"
|
|
||||||
#include "display/spritebatch/spritebatchsprite.h"
|
|
||||||
#include "display/color.h"
|
|
||||||
|
|
||||||
#define UI_LABEL_TEXT_MAX 256
|
|
||||||
#define UI_LABEL_SPRITE_COUNT_MAX UI_LABEL_TEXT_MAX
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
char_t text[UI_LABEL_TEXT_MAX];
|
|
||||||
color_t color;
|
|
||||||
font_t *font;
|
|
||||||
|
|
||||||
spritebatchsprite_t sprites[UI_LABEL_SPRITE_COUNT_MAX];
|
|
||||||
uint32_t spriteCount;
|
|
||||||
int32_t width;
|
|
||||||
int32_t height;
|
|
||||||
} uilabel_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes a label, defaulting to white text and no text set.
|
|
||||||
*
|
|
||||||
* @param label The label to initialize.
|
|
||||||
* @param font Font to use for rendering. Must outlive the label.
|
|
||||||
*/
|
|
||||||
void uiLabelInit(uilabel_t *label, font_t *font);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the label's text, rebuilding its cached sprites (glyph lookup, UV,
|
|
||||||
* and layout) immediately. This is the only way the sprite cache gets
|
|
||||||
* rebuilt -- uiLabelDraw never recomputes it, so call this whenever the
|
|
||||||
* text changes rather than once up front and expecting it to stay in sync.
|
|
||||||
*
|
|
||||||
* @param label The label to update.
|
|
||||||
* @param text Null-terminated string to display. Must be shorter than
|
|
||||||
* UI_LABEL_TEXT_MAX.
|
|
||||||
*/
|
|
||||||
void uiLabelSetText(uilabel_t *label, const char_t *text);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the label's tint color. Cheap -- doesn't touch the sprite cache,
|
|
||||||
* since color is applied via the draw material, not baked into sprites.
|
|
||||||
*
|
|
||||||
* @param label The label to update.
|
|
||||||
* @param color The new tint color.
|
|
||||||
*/
|
|
||||||
void uiLabelSetColor(uilabel_t *label, const color_t color);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the measured size (in pixels) of the label's current text, cached
|
|
||||||
* from the last uiLabelSetText call.
|
|
||||||
*
|
|
||||||
* @param label The label to query.
|
|
||||||
* @param outWidth Pointer to store the width.
|
|
||||||
* @param outHeight Pointer to store the height.
|
|
||||||
*/
|
|
||||||
void uiLabelGetSize(
|
|
||||||
const uilabel_t *label,
|
|
||||||
int32_t *outWidth,
|
|
||||||
int32_t *outHeight
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws the label's cached sprites at the given screen position in a
|
|
||||||
* single batched buffer call. Does not recompute glyph layout -- call
|
|
||||||
* uiLabelSetText first whenever the text changes.
|
|
||||||
*
|
|
||||||
* @param label The label to draw.
|
|
||||||
* @param x Screen x position.
|
|
||||||
* @param y Screen y position.
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiLabelDraw(
|
|
||||||
const uilabel_t *label,
|
|
||||||
const float_t x,
|
|
||||||
const float_t y
|
|
||||||
);
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uiwidgetlabel.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "util/string.h"
|
|
||||||
#include "display/text/text.h"
|
|
||||||
|
|
||||||
void uiWidgetLabelInit(uiwidgetlabel_t *label, font_t *font) {
|
|
||||||
assertNotNull(label, "Label cannot be NULL");
|
|
||||||
assertNotNull(font, "Font cannot be NULL");
|
|
||||||
|
|
||||||
memoryZero(label, sizeof(uiwidgetlabel_t));
|
|
||||||
label->font = font;
|
|
||||||
label->color = COLOR_WHITE;
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiWidgetLabelSetText(uiwidgetlabel_t *label, const char_t *text) {
|
|
||||||
assertNotNull(label, "Label cannot be NULL");
|
|
||||||
assertNotNull(text, "Text cannot be NULL");
|
|
||||||
assertStrLenMax(text, UI_WIDGET_LABEL_TEXT_MAX, "Label text too long");
|
|
||||||
|
|
||||||
stringCopy(label->text, text, UI_WIDGET_LABEL_TEXT_MAX);
|
|
||||||
label->spriteCount = textBuildSpriteCache(
|
|
||||||
label->text, label->font, label->sprites,
|
|
||||||
UI_WIDGET_LABEL_SPRITE_COUNT_MAX, &label->width, &label->height
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiWidgetLabelSetColor(uiwidgetlabel_t *label, const color_t color) {
|
|
||||||
assertNotNull(label, "Label cannot be NULL");
|
|
||||||
label->color = color;
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiWidgetLabelGetSize(
|
|
||||||
const uiwidgetlabel_t *label,
|
|
||||||
int32_t *outWidth,
|
|
||||||
int32_t *outHeight
|
|
||||||
) {
|
|
||||||
assertNotNull(label, "Label cannot be NULL");
|
|
||||||
assertNotNull(outWidth, "Output width cannot be NULL");
|
|
||||||
assertNotNull(outHeight, "Output height cannot be NULL");
|
|
||||||
*outWidth = label->width;
|
|
||||||
*outHeight = label->height;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiWidgetLabelDraw(
|
|
||||||
const uiwidgetlabel_t *label,
|
|
||||||
const float_t x,
|
|
||||||
const float_t y
|
|
||||||
) {
|
|
||||||
assertNotNull(label, "Label cannot be NULL");
|
|
||||||
if(label->spriteCount == 0) errorOk();
|
|
||||||
|
|
||||||
spritebatchsprite_t scratch[UI_WIDGET_LABEL_SPRITE_COUNT_MAX];
|
|
||||||
errorChain(textDrawSpriteCache(
|
|
||||||
label->sprites, label->spriteCount, scratch, x, y, label->color,
|
|
||||||
label->font->texture
|
|
||||||
));
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "display/text/font.h"
|
|
||||||
#include "display/spritebatch/spritebatchsprite.h"
|
|
||||||
#include "display/color.h"
|
|
||||||
|
|
||||||
// A cached, cheap-to-redraw label sized for embedding directly inside
|
|
||||||
// small widgets (buttons, checkboxes, sliders, ...) rather than
|
|
||||||
// referencing text by pointer and re-deriving glyph geometry every
|
|
||||||
// frame. Capacity is intentionally much smaller than uilabel_t's -- it
|
|
||||||
// gets embedded by value in every widget instance (and inside
|
|
||||||
// uimenuitem_t's union, which is often arrayed), so keeping it small
|
|
||||||
// matters on memory-constrained targets. See uilabel_t for the
|
|
||||||
// general-purpose, larger-capacity equivalent.
|
|
||||||
#define UI_WIDGET_LABEL_TEXT_MAX 64
|
|
||||||
#define UI_WIDGET_LABEL_SPRITE_COUNT_MAX UI_WIDGET_LABEL_TEXT_MAX
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
char_t text[UI_WIDGET_LABEL_TEXT_MAX];
|
|
||||||
color_t color;
|
|
||||||
font_t *font;
|
|
||||||
|
|
||||||
spritebatchsprite_t sprites[UI_WIDGET_LABEL_SPRITE_COUNT_MAX];
|
|
||||||
uint32_t spriteCount;
|
|
||||||
int32_t width;
|
|
||||||
int32_t height;
|
|
||||||
} uiwidgetlabel_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes a widget label, defaulting to white text and no text set.
|
|
||||||
*
|
|
||||||
* @param label The label to initialize.
|
|
||||||
* @param font Font to use for rendering. Must outlive the label.
|
|
||||||
*/
|
|
||||||
void uiWidgetLabelInit(uiwidgetlabel_t *label, font_t *font);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the label's text, rebuilding its cached sprites immediately.
|
|
||||||
* Call this whenever the displayed text changes -- uiWidgetLabelDraw
|
|
||||||
* never recomputes it.
|
|
||||||
*
|
|
||||||
* @param label The label to update.
|
|
||||||
* @param text Null-terminated string to display. Must be shorter than
|
|
||||||
* UI_WIDGET_LABEL_TEXT_MAX.
|
|
||||||
*/
|
|
||||||
void uiWidgetLabelSetText(uiwidgetlabel_t *label, const char_t *text);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the label's tint color. Cheap -- doesn't touch the sprite cache.
|
|
||||||
*
|
|
||||||
* @param label The label to update.
|
|
||||||
* @param color The new tint color.
|
|
||||||
*/
|
|
||||||
void uiWidgetLabelSetColor(uiwidgetlabel_t *label, const color_t color);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the measured size (in pixels) of the label's current text, cached
|
|
||||||
* from the last uiWidgetLabelSetText call.
|
|
||||||
*
|
|
||||||
* @param label The label to query.
|
|
||||||
* @param outWidth Pointer to store the width.
|
|
||||||
* @param outHeight Pointer to store the height.
|
|
||||||
*/
|
|
||||||
void uiWidgetLabelGetSize(
|
|
||||||
const uiwidgetlabel_t *label,
|
|
||||||
int32_t *outWidth,
|
|
||||||
int32_t *outHeight
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws the label's cached sprites at the given screen position in a
|
|
||||||
* single batched buffer call. Does not recompute glyph layout.
|
|
||||||
*
|
|
||||||
* @param label The label to draw.
|
|
||||||
* @param x Screen x position.
|
|
||||||
* @param y Screen y position.
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiWidgetLabelDraw(
|
|
||||||
const uiwidgetlabel_t *label,
|
|
||||||
const float_t x,
|
|
||||||
const float_t y
|
|
||||||
);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# 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
|
|
||||||
uitextbox.c
|
|
||||||
uitextboxmain.c
|
|
||||||
)
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uitextbox.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "util/string.h"
|
|
||||||
#include "time/time.h"
|
|
||||||
#include "display/text/text.h"
|
|
||||||
#include "display/color.h"
|
|
||||||
#include "display/spritebatch/spritebatch.h"
|
|
||||||
#include "display/shader/shaderunlit.h"
|
|
||||||
#include "ui/frame/uiframe.h"
|
|
||||||
|
|
||||||
void uiTextboxInit(
|
|
||||||
uitextbox_t *box,
|
|
||||||
char_t *text,
|
|
||||||
const uint32_t maxLength,
|
|
||||||
uitextboxline_t *lines,
|
|
||||||
const uint32_t linesMax
|
|
||||||
) {
|
|
||||||
assertNotNull(box, "Textbox cannot be NULL");
|
|
||||||
assertNotNull(text, "Text buffer cannot be NULL");
|
|
||||||
assertTrue(maxLength >= 1, "maxLength must be at least 1");
|
|
||||||
assertNotNull(lines, "Lines buffer cannot be NULL");
|
|
||||||
assertTrue(linesMax >= 1, "linesMax must be at least 1");
|
|
||||||
memoryZero(box, sizeof(uitextbox_t));
|
|
||||||
box->text = text;
|
|
||||||
box->maxLength = maxLength;
|
|
||||||
box->lines = lines;
|
|
||||||
box->linesMax = linesMax;
|
|
||||||
box->glyphsBuiltForPage = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiTextboxSetText(uitextbox_t *box, const char_t *text) {
|
|
||||||
assertNotNull(box, "Textbox cannot be NULL");
|
|
||||||
assertNotNull(text, "Text cannot be NULL");
|
|
||||||
stringCopy(box->text, text, box->maxLength);
|
|
||||||
box->currentPage = 0;
|
|
||||||
box->scroll = 0;
|
|
||||||
box->layoutWidth = 0.0f;
|
|
||||||
box->layoutHeight = 0.0f;
|
|
||||||
box->glyphsBuiltForPage = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiTextboxBuildLayout(
|
|
||||||
uitextbox_t *box,
|
|
||||||
const float_t width,
|
|
||||||
const float_t height
|
|
||||||
) {
|
|
||||||
assertNotNull(box, "Textbox cannot be NULL");
|
|
||||||
|
|
||||||
box->glyphsBuiltForPage = -1;
|
|
||||||
box->layoutWidth = width;
|
|
||||||
box->layoutHeight = height;
|
|
||||||
box->lineCount = 0;
|
|
||||||
box->pageCount = 1;
|
|
||||||
|
|
||||||
float_t fontW = (float_t)FONT_DEFAULT.tileset->tileWidth;
|
|
||||||
float_t fontH = (float_t)FONT_DEFAULT.tileset->tileHeight;
|
|
||||||
|
|
||||||
if(fontW <= 0.0f || fontH <= 0.0f) return;
|
|
||||||
|
|
||||||
box->charsPerLine = (int32_t)(width / fontW);
|
|
||||||
box->linesPerPage = (int32_t)(height / (fontH + UI_TEXTBOX_LINE_SPACING));
|
|
||||||
if(box->linesPerPage > UI_TEXTBOX_LINES_PER_PAGE_MAX) {
|
|
||||||
box->linesPerPage = UI_TEXTBOX_LINES_PER_PAGE_MAX;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(box->charsPerLine <= 0 || box->linesPerPage <= 0) return;
|
|
||||||
if(box->text[0] == '\0') return;
|
|
||||||
|
|
||||||
char_t *src = box->text;
|
|
||||||
int32_t i = 0;
|
|
||||||
|
|
||||||
while(src[i] != '\0' && box->lineCount < (int32_t)box->linesMax) {
|
|
||||||
if(src[i] == '\t') {
|
|
||||||
i++;
|
|
||||||
int32_t rem = box->lineCount % box->linesPerPage;
|
|
||||||
int32_t pad = rem > 0 ? box->linesPerPage - rem : 0;
|
|
||||||
while(pad > 0 && box->lineCount < (int32_t)box->linesMax) {
|
|
||||||
box->lines[box->lineCount].start = i;
|
|
||||||
box->lines[box->lineCount].count = 0;
|
|
||||||
box->lineCount++;
|
|
||||||
pad--;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
int32_t lineStart = i;
|
|
||||||
int32_t lineWidth = 0;
|
|
||||||
|
|
||||||
while(src[i] != '\0') {
|
|
||||||
char_t c = src[i];
|
|
||||||
|
|
||||||
if(c == '\n') { i++; break; }
|
|
||||||
if(c == '\t') break;
|
|
||||||
|
|
||||||
if(c == ' ') {
|
|
||||||
int32_t wordLen = 0;
|
|
||||||
int32_t j = i + 1;
|
|
||||||
while(
|
|
||||||
src[j] != ' ' && src[j] != '\n' &&
|
|
||||||
src[j] != '\t' && src[j] != '\0'
|
|
||||||
) {
|
|
||||||
wordLen++;
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(lineWidth > 0 && lineWidth + 1 + wordLen > box->charsPerLine) {
|
|
||||||
i++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
lineWidth++;
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
if(lineWidth >= box->charsPerLine) break;
|
|
||||||
lineWidth++;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
box->lines[box->lineCount].start = lineStart;
|
|
||||||
box->lines[box->lineCount].count = lineWidth;
|
|
||||||
box->lineCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(box->lineCount == 0) {
|
|
||||||
box->pageCount = 1;
|
|
||||||
} else {
|
|
||||||
box->pageCount =
|
|
||||||
(box->lineCount + box->linesPerPage - 1) / box->linesPerPage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiTextboxUpdate(uitextbox_t *box) {
|
|
||||||
assertNotNull(box, "Textbox cannot be NULL");
|
|
||||||
|
|
||||||
#ifdef DUSK_TIME_DYNAMIC
|
|
||||||
if(TIME.dynamicUpdate) errorOk();
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if(!uiTextboxPageIsComplete(box)) {
|
|
||||||
box->scroll += UI_TEXTBOX_SCROLL_CHARS_PER_TICK;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiTextboxDraw(
|
|
||||||
uitextbox_t *box,
|
|
||||||
const float_t x,
|
|
||||||
const float_t y,
|
|
||||||
const float_t width,
|
|
||||||
const float_t height
|
|
||||||
) {
|
|
||||||
assertNotNull(box, "Textbox cannot be NULL");
|
|
||||||
|
|
||||||
float_t startX = (float_t)UI_FRAME_START_X;
|
|
||||||
float_t startY = (float_t)UI_FRAME_START_Y;
|
|
||||||
float_t contentX = x + startX;
|
|
||||||
float_t contentY = y + startY;
|
|
||||||
float_t contentW = width - 2.0f * startX;
|
|
||||||
float_t contentH = height - 2.0f * startY;
|
|
||||||
|
|
||||||
if(contentW != box->layoutWidth || contentH != box->layoutHeight) {
|
|
||||||
uiTextboxBuildLayout(box, contentW, contentH);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorChain(uiFrameDrawCached(&box->frameCache, x, y, width, height));
|
|
||||||
|
|
||||||
if(box->lineCount == 0 || box->text[0] == '\0') errorOk();
|
|
||||||
|
|
||||||
if(box->glyphsBuiltForPage != box->currentPage) {
|
|
||||||
uiTextboxBuildPageGlyphs(box);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(box->glyphCount == 0) errorOk();
|
|
||||||
|
|
||||||
int32_t visibleCount = 0;
|
|
||||||
while(
|
|
||||||
visibleCount < box->glyphCount &&
|
|
||||||
box->glyphs[visibleCount].revealAt <= box->scroll
|
|
||||||
) visibleCount++;
|
|
||||||
|
|
||||||
if(visibleCount == 0) errorOk();
|
|
||||||
|
|
||||||
spritebatchsprite_t scratch[UI_TEXTBOX_PAGE_GLYPHS_MAX];
|
|
||||||
for(int32_t i = 0; i < visibleCount; i++) {
|
|
||||||
scratch[i] = spriteBatchSpriteTranslate(
|
|
||||||
&box->glyphs[i].sprite, contentX, contentY
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
shadermaterial_t material = {
|
|
||||||
.unlit = {
|
|
||||||
.color = COLOR_WHITE,
|
|
||||||
.texture = FONT_DEFAULT.texture
|
|
||||||
}
|
|
||||||
};
|
|
||||||
errorChain(spriteBatchBuffer(scratch, visibleCount, &SHADER_UNLIT, material));
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiTextboxBuildPageGlyphs(uitextbox_t *box) {
|
|
||||||
assertNotNull(box, "Textbox cannot be NULL");
|
|
||||||
|
|
||||||
box->glyphCount = 0;
|
|
||||||
|
|
||||||
int32_t pageFirst = box->currentPage * box->linesPerPage;
|
|
||||||
int32_t pageLast = pageFirst + box->linesPerPage;
|
|
||||||
if(pageLast > box->lineCount) pageLast = box->lineCount;
|
|
||||||
|
|
||||||
float_t fontW = (float_t)FONT_DEFAULT.tileset->tileWidth;
|
|
||||||
float_t fontH = (float_t)FONT_DEFAULT.tileset->tileHeight;
|
|
||||||
|
|
||||||
int32_t consumed = 0;
|
|
||||||
for(int32_t li = pageFirst; li < pageLast; li++) {
|
|
||||||
uitextboxline_t *line = &box->lines[li];
|
|
||||||
float_t lineY =
|
|
||||||
(float_t)(li - pageFirst) * (fontH + UI_TEXTBOX_LINE_SPACING);
|
|
||||||
|
|
||||||
for(int32_t ci = 0; ci < line->count; ci++) {
|
|
||||||
consumed++;
|
|
||||||
char_t c = box->text[line->start + ci];
|
|
||||||
if(c == ' ') continue;
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
box->glyphCount < UI_TEXTBOX_PAGE_GLYPHS_MAX,
|
|
||||||
"Textbox page produces too many glyphs"
|
|
||||||
);
|
|
||||||
uitextboxglyph_t *glyph = &box->glyphs[box->glyphCount++];
|
|
||||||
glyph->sprite = textGetSprite(
|
|
||||||
(vec2){ (float_t)ci * fontW, lineY }, c, &FONT_DEFAULT
|
|
||||||
);
|
|
||||||
glyph->revealAt = consumed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
box->glyphsBuiltForPage = box->currentPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
int32_t uiTextboxGetPageCharCount(const uitextbox_t *box) {
|
|
||||||
assertNotNull(box, "Textbox cannot be NULL");
|
|
||||||
int32_t first = box->currentPage * box->linesPerPage;
|
|
||||||
int32_t last = first + box->linesPerPage;
|
|
||||||
if(last > box->lineCount) last = box->lineCount;
|
|
||||||
int32_t total = 0;
|
|
||||||
for(int32_t i = first; i < last; i++) {
|
|
||||||
total += box->lines[i].count;
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t uiTextboxPageIsComplete(const uitextbox_t *box) {
|
|
||||||
assertNotNull(box, "Textbox cannot be NULL");
|
|
||||||
return box->scroll >= uiTextboxGetPageCharCount(box);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t uiTextboxHasNextPage(const uitextbox_t *box) {
|
|
||||||
assertNotNull(box, "Textbox cannot be NULL");
|
|
||||||
return box->currentPage + 1 < box->pageCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiTextboxNextPage(uitextbox_t *box) {
|
|
||||||
assertNotNull(box, "Textbox cannot be NULL");
|
|
||||||
if(!uiTextboxHasNextPage(box)) return;
|
|
||||||
box->currentPage++;
|
|
||||||
box->scroll = 0;
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
#include "ui/frame/uiframe.h"
|
|
||||||
#include "display/spritebatch/spritebatchsprite.h"
|
|
||||||
|
|
||||||
#define UI_TEXTBOX_LINES_PER_PAGE_MAX 4
|
|
||||||
#define UI_TEXTBOX_SCROLL_CHARS_PER_TICK 1
|
|
||||||
#define UI_TEXTBOX_LINE_SPACING 0.0f
|
|
||||||
|
|
||||||
// Fixed capacity for a page's cached glyph sprites (see uitextboxglyph_t).
|
|
||||||
// Sized generously above UI_TEXTBOX_LINES_PER_PAGE_MAX worth of glyphs at
|
|
||||||
// typical textbox widths; uiTextboxBuildPageGlyphs asserts if a page
|
|
||||||
// somehow produces more than this.
|
|
||||||
#define UI_TEXTBOX_PAGE_GLYPHS_MAX 512
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int32_t start;
|
|
||||||
int32_t count;
|
|
||||||
} uitextboxline_t;
|
|
||||||
|
|
||||||
// A single visible glyph's cached sprite (relative to the textbox's
|
|
||||||
// content origin, i.e. (0,0)) plus the scroll value at/after which it
|
|
||||||
// becomes visible -- lets uiTextboxDraw turn the typewriter scroll into
|
|
||||||
// a simple prefix-count instead of recomputing glyph geometry every
|
|
||||||
// frame.
|
|
||||||
typedef struct {
|
|
||||||
spritebatchsprite_t sprite;
|
|
||||||
int32_t revealAt;
|
|
||||||
} uitextboxglyph_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
char_t *text;
|
|
||||||
uint32_t maxLength;
|
|
||||||
|
|
||||||
uitextboxline_t *lines;
|
|
||||||
uint32_t linesMax;
|
|
||||||
int32_t lineCount;
|
|
||||||
int32_t charsPerLine;
|
|
||||||
int32_t linesPerPage;
|
|
||||||
int32_t pageCount;
|
|
||||||
|
|
||||||
// last dimensions used for layout; rebuild triggers when these change
|
|
||||||
float_t layoutWidth;
|
|
||||||
float_t layoutHeight;
|
|
||||||
|
|
||||||
int32_t currentPage;
|
|
||||||
int32_t scroll;
|
|
||||||
|
|
||||||
// Cached glyph sprites for the current page (see uitextboxglyph_t),
|
|
||||||
// rebuilt only when currentPage no longer matches
|
|
||||||
// glyphsBuiltForPage -- not every frame/scroll tick.
|
|
||||||
uitextboxglyph_t glyphs[UI_TEXTBOX_PAGE_GLYPHS_MAX];
|
|
||||||
int32_t glyphCount;
|
|
||||||
int32_t glyphsBuiltForPage;
|
|
||||||
|
|
||||||
uiframecache_t frameCache;
|
|
||||||
} uitextbox_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes a textbox, zeroing all state and binding it to caller-owned
|
|
||||||
* text and line storage.
|
|
||||||
*
|
|
||||||
* @param box The textbox to initialize.
|
|
||||||
* @param text Caller-owned buffer the textbox copies its text into.
|
|
||||||
* @param maxLength Capacity of text, in characters.
|
|
||||||
* @param lines Caller-owned buffer the textbox lays lines out into.
|
|
||||||
* @param linesMax Capacity of lines, in entries.
|
|
||||||
*/
|
|
||||||
void uiTextboxInit(
|
|
||||||
uitextbox_t *box,
|
|
||||||
char_t *text,
|
|
||||||
const uint32_t maxLength,
|
|
||||||
uitextboxline_t *lines,
|
|
||||||
const uint32_t linesMax
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copies text into the textbox and marks layout as dirty.
|
|
||||||
* Resets currentPage and scroll to 0.
|
|
||||||
*
|
|
||||||
* @param box The textbox to update.
|
|
||||||
* @param text Null-terminated source string.
|
|
||||||
*/
|
|
||||||
void uiTextboxSetText(uitextbox_t *box, const char_t *text);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuilds word-wrap and page layout for the given draw dimensions.
|
|
||||||
* Called automatically by uiTextboxDraw when width or height changes.
|
|
||||||
*
|
|
||||||
* @param box The textbox to rebuild.
|
|
||||||
* @param width Available content width in pixels.
|
|
||||||
* @param height Available content height in pixels.
|
|
||||||
*/
|
|
||||||
void uiTextboxBuildLayout(
|
|
||||||
uitextbox_t *box,
|
|
||||||
const float_t width,
|
|
||||||
const float_t height
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Advances the typewriter scroll by UI_TEXTBOX_SCROLL_CHARS_PER_TICK.
|
|
||||||
* Skipped on dynamic ticks.
|
|
||||||
*
|
|
||||||
* @param box The textbox to update.
|
|
||||||
* @returns Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiTextboxUpdate(uitextbox_t *box);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws the textbox frame and visible text. Rebuilds layout automatically
|
|
||||||
* if width or height differs from the last draw call.
|
|
||||||
*
|
|
||||||
* @param box The textbox to draw.
|
|
||||||
* @param x Screen x position.
|
|
||||||
* @param y Screen y position.
|
|
||||||
* @param width Draw width in pixels.
|
|
||||||
* @param height Draw height in pixels.
|
|
||||||
* @returns Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiTextboxDraw(
|
|
||||||
uitextbox_t *box,
|
|
||||||
const float_t x,
|
|
||||||
const float_t y,
|
|
||||||
const float_t width,
|
|
||||||
const float_t height
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the total visible char count for the current page.
|
|
||||||
*
|
|
||||||
* @param box The textbox to query.
|
|
||||||
* @returns Total chars on the current page.
|
|
||||||
*/
|
|
||||||
int32_t uiTextboxGetPageCharCount(const uitextbox_t *box);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true when scroll has fully revealed the current page.
|
|
||||||
*
|
|
||||||
* @param box The textbox to query.
|
|
||||||
* @returns True if the current page is fully visible.
|
|
||||||
*/
|
|
||||||
bool_t uiTextboxPageIsComplete(const uitextbox_t *box);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true when there is at least one more page after the current one.
|
|
||||||
*
|
|
||||||
* @param box The textbox to query.
|
|
||||||
* @returns True if a next page exists.
|
|
||||||
*/
|
|
||||||
bool_t uiTextboxHasNextPage(const uitextbox_t *box);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Advances to the next page and resets scroll to 0.
|
|
||||||
* Has no effect if already on the last page.
|
|
||||||
*
|
|
||||||
* @param box The textbox to advance.
|
|
||||||
*/
|
|
||||||
void uiTextboxNextPage(uitextbox_t *box);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuilds the cached glyph sprites (see uitextboxglyph_t) for the
|
|
||||||
* current page from its line layout. Called automatically by
|
|
||||||
* uiTextboxDraw whenever currentPage no longer matches the page the
|
|
||||||
* cache was last built for.
|
|
||||||
*
|
|
||||||
* @param box The textbox to rebuild.
|
|
||||||
*/
|
|
||||||
void uiTextboxBuildPageGlyphs(uitextbox_t *box);
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uitextboxmain.h"
|
|
||||||
#include "ui/focus/uifocus.h"
|
|
||||||
#include "display/screen/screen.h"
|
|
||||||
#include "display/text/text.h"
|
|
||||||
#include "display/color.h"
|
|
||||||
#include "display/spritebatch/spritebatch.h"
|
|
||||||
#include "display/shader/shaderunlit.h"
|
|
||||||
#include "ui/frame/uiframe.h"
|
|
||||||
|
|
||||||
uitextboxmain_t UI_TEXTBOX_MAIN;
|
|
||||||
static uifocusitem_t *focusItem = NULL;
|
|
||||||
|
|
||||||
errorret_t uiTextboxMainInit(void) {
|
|
||||||
uiTextboxInit(
|
|
||||||
&UI_TEXTBOX_MAIN.box,
|
|
||||||
UI_TEXTBOX_MAIN.text, UI_TEXTBOX_MAIN_TEXT_MAX,
|
|
||||||
UI_TEXTBOX_MAIN.lines, UI_TEXTBOX_MAIN_LINES_MAX
|
|
||||||
);
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiTextboxMainSetText(const char_t *text) {
|
|
||||||
uiTextboxSetText(&UI_TEXTBOX_MAIN.box, text);
|
|
||||||
if(focusItem != NULL) return;
|
|
||||||
focusItem = uiFocusPush(
|
|
||||||
1, 1,
|
|
||||||
uiTextboxMainFocusSelected,
|
|
||||||
NULL,
|
|
||||||
uiTextboxMainFocusClosed,
|
|
||||||
NULL,
|
|
||||||
NULL
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiTextboxMainUpdate(void) {
|
|
||||||
if(focusItem == NULL) errorOk();
|
|
||||||
return uiTextboxUpdate(&UI_TEXTBOX_MAIN.box);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiTextboxMainDraw(void) {
|
|
||||||
if(focusItem == NULL) errorOk();
|
|
||||||
float_t fontH = (float_t)FONT_DEFAULT.tileset->tileHeight;
|
|
||||||
float_t h = (float_t)UI_TEXTBOX_MAIN_LINES * fontH +
|
|
||||||
(float_t)(UI_TEXTBOX_MAIN_LINES - 1) * UI_TEXTBOX_LINE_SPACING +
|
|
||||||
2.0f * (float_t)UI_FRAME_START_Y;
|
|
||||||
float_t w = (float_t)SCREEN.scanWidth;
|
|
||||||
float_t x = (float_t)SCREEN.scanX;
|
|
||||||
float_t y = (float_t)(SCREEN.scanY + SCREEN.scanHeight) - h;
|
|
||||||
errorChain(uiTextboxDraw(&UI_TEXTBOX_MAIN.box, x, y, w, h));
|
|
||||||
|
|
||||||
if(!uiTextboxPageIsComplete(&UI_TEXTBOX_MAIN.box)) errorOk();
|
|
||||||
|
|
||||||
float_t fontW = (float_t)FONT_DEFAULT.tileset->tileWidth;
|
|
||||||
float_t contentX = x + (float_t)UI_FRAME_START_X;
|
|
||||||
float_t contentY = y + (float_t)UI_FRAME_START_Y;
|
|
||||||
float_t contentW = w - 2.0f * (float_t)UI_FRAME_START_X;
|
|
||||||
float_t contentH = h - 2.0f * (float_t)UI_FRAME_START_Y;
|
|
||||||
|
|
||||||
shadermaterial_t material = {
|
|
||||||
.unlit = {
|
|
||||||
.color = COLOR_WHITE,
|
|
||||||
.texture = FONT_DEFAULT.texture
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
spritebatchsprite_t caret = textGetSprite(
|
|
||||||
(vec2){
|
|
||||||
contentX + contentW - fontW,
|
|
||||||
contentY + contentH - fontH
|
|
||||||
},
|
|
||||||
'v',
|
|
||||||
&FONT_DEFAULT
|
|
||||||
);
|
|
||||||
errorChain(spriteBatchBuffer(&caret, 1, &SHADER_UNLIT, material));
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t uiTextboxMainPageIsComplete(void) {
|
|
||||||
return uiTextboxPageIsComplete(&UI_TEXTBOX_MAIN.box);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t uiTextboxMainHasNextPage(void) {
|
|
||||||
return uiTextboxHasNextPage(&UI_TEXTBOX_MAIN.box);
|
|
||||||
}
|
|
||||||
|
|
||||||
void uiTextboxMainNextPage(void) {
|
|
||||||
uiTextboxNextPage(&UI_TEXTBOX_MAIN.box);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t uiTextboxMainIsActive(void) {
|
|
||||||
return focusItem != NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t uiTextboxMainFocusSelected(const uifocusitem_t *item) {
|
|
||||||
if(!uiTextboxPageIsComplete(&UI_TEXTBOX_MAIN.box)) {
|
|
||||||
UI_TEXTBOX_MAIN.box.scroll =
|
|
||||||
uiTextboxGetPageCharCount(&UI_TEXTBOX_MAIN.box);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(uiTextboxHasNextPage(&UI_TEXTBOX_MAIN.box)) {
|
|
||||||
uiTextboxNextPage(&UI_TEXTBOX_MAIN.box);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
uiFocusPopItem(focusItem);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t uiTextboxMainFocusClosed(const uifocusitem_t *item) {
|
|
||||||
focusItem = NULL;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "uitextbox.h"
|
|
||||||
#include "ui/focus/uifocusitem.h"
|
|
||||||
|
|
||||||
#define UI_TEXTBOX_MAIN_LINES 4
|
|
||||||
#define UI_TEXTBOX_MAIN_TEXT_MAX 1024
|
|
||||||
#define UI_TEXTBOX_MAIN_LINES_MAX 64
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uitextbox_t box;
|
|
||||||
char_t text[UI_TEXTBOX_MAIN_TEXT_MAX];
|
|
||||||
uitextboxline_t lines[UI_TEXTBOX_MAIN_LINES_MAX];
|
|
||||||
} uitextboxmain_t;
|
|
||||||
|
|
||||||
extern uitextboxmain_t UI_TEXTBOX_MAIN;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes UI_TEXTBOX_MAIN.
|
|
||||||
*
|
|
||||||
* @returns Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiTextboxMainInit(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copies text into UI_TEXTBOX_MAIN and resets page and scroll.
|
|
||||||
*
|
|
||||||
* @param text Null-terminated source string.
|
|
||||||
*/
|
|
||||||
void uiTextboxMainSetText(const char_t *text);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Advances the typewriter scroll for UI_TEXTBOX_MAIN.
|
|
||||||
*
|
|
||||||
* @returns Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiTextboxMainUpdate(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws UI_TEXTBOX_MAIN full-width at the bottom of the screen.
|
|
||||||
* Position and size are derived from SCREEN each call.
|
|
||||||
*
|
|
||||||
* @returns Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiTextboxMainDraw(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true when the current page is fully scrolled in.
|
|
||||||
*
|
|
||||||
* @returns True if the current page is complete.
|
|
||||||
*/
|
|
||||||
bool_t uiTextboxMainPageIsComplete(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true when at least one more page follows the current one.
|
|
||||||
*
|
|
||||||
* @returns True if a next page exists.
|
|
||||||
*/
|
|
||||||
bool_t uiTextboxMainHasNextPage(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Advances UI_TEXTBOX_MAIN to the next page and resets scroll.
|
|
||||||
* Has no effect if already on the last page.
|
|
||||||
*/
|
|
||||||
void uiTextboxMainNextPage(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true when UI_TEXTBOX_MAIN has focus (is visible and active).
|
|
||||||
*
|
|
||||||
* @returns True if the textbox is currently active.
|
|
||||||
*/
|
|
||||||
bool_t uiTextboxMainIsActive(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal focus callback - skip scroll or advance page or dismiss.
|
|
||||||
*
|
|
||||||
* @param item The active focus item.
|
|
||||||
* @returns True.
|
|
||||||
*/
|
|
||||||
bool_t uiTextboxMainFocusSelected(const uifocusitem_t *item);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal focus callback - clears the focus item pointer on dismiss.
|
|
||||||
*
|
|
||||||
* @param item The focus item being closed.
|
|
||||||
* @returns True.
|
|
||||||
*/
|
|
||||||
bool_t uiTextboxMainFocusClosed(const uifocusitem_t *item);
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "uitestlabel.h"
|
|
||||||
#include "ui/widget/uilabel.h"
|
|
||||||
#include "display/text/text.h"
|
|
||||||
#include "display/screen/screen.h"
|
|
||||||
#include "engine/engine.h"
|
|
||||||
|
|
||||||
uilabel_t UI_TEST_LABEL;
|
|
||||||
|
|
||||||
errorret_t uiTestLabelInit() {
|
|
||||||
uiLabelInit(&UI_TEST_LABEL, &FONT_DEFAULT);
|
|
||||||
uiLabelSetText(&UI_TEST_LABEL, "duskrpg");
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t uiTestLabelDraw() {
|
|
||||||
int32_t versionWidth, versionHeight;
|
|
||||||
textMeasure(ENGINE.version, &FONT_DEFAULT, &versionWidth, &versionHeight);
|
|
||||||
|
|
||||||
errorChain(uiLabelDraw(
|
|
||||||
&UI_TEST_LABEL,
|
|
||||||
(float_t)(SCREEN.scanX + SCREEN.scanWidth - UI_TEST_LABEL.width),
|
|
||||||
(float_t)(
|
|
||||||
SCREEN.scanY + SCREEN.scanHeight - versionHeight -
|
|
||||||
UI_TEST_LABEL.height
|
|
||||||
)
|
|
||||||
));
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the test label, a smoke test for the duskrpg-side UI
|
|
||||||
* element extension point (uielementgame.h).
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiTestLabelInit();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws the test label above the engine's debug version number.
|
|
||||||
*
|
|
||||||
* @return Any error that occurs.
|
|
||||||
*/
|
|
||||||
errorret_t uiTestLabelDraw();
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Copyright (c) 2026 Dominic Masters
|
|
||||||
#
|
|
||||||
# This software is released under the MIT License.
|
|
||||||
# https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
include(dusktest)
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
dusktest(test_uiframe.c)
|
|
||||||
dusktest(test_uislider.c)
|
|
||||||
dusktest(test_uitab.c)
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "dusktest.h"
|
|
||||||
#include "ui/frame/uiframe.h"
|
|
||||||
|
|
||||||
static void test_uiFrameBuildSprites_layout(void **state) {
|
|
||||||
UI_FRAME.tileset = (tileset_t){
|
|
||||||
.tileWidth = 1,
|
|
||||||
.tileHeight = 1,
|
|
||||||
.tileCount = 9,
|
|
||||||
.columns = 3,
|
|
||||||
.rows = 3,
|
|
||||||
.uv = { 0.25f, 0.25f }
|
|
||||||
};
|
|
||||||
|
|
||||||
const float_t x = 100.0f;
|
|
||||||
const float_t y = 50.0f;
|
|
||||||
const float_t width = 40.0f;
|
|
||||||
const float_t height = 30.0f;
|
|
||||||
const float_t tileW = (float_t)UI_FRAME_BORDER_WIDTH;
|
|
||||||
const float_t tileH = (float_t)UI_FRAME_BORDER_HEIGHT;
|
|
||||||
|
|
||||||
spritebatchsprite_t sprites[9];
|
|
||||||
uiFrameBuildSprites(sprites, x, y, width, height);
|
|
||||||
|
|
||||||
// Top-left corner sits exactly at the rect's origin.
|
|
||||||
assert_float_equal(sprites[0].min[0], x, 0.0001f);
|
|
||||||
assert_float_equal(sprites[0].min[1], y, 0.0001f);
|
|
||||||
assert_float_equal(sprites[0].max[0], x + tileW, 0.0001f);
|
|
||||||
assert_float_equal(sprites[0].max[1], y + tileH, 0.0001f);
|
|
||||||
assert_float_equal(sprites[0].uvMin[0], 0.0f, 0.0001f);
|
|
||||||
assert_float_equal(sprites[0].uvMin[1], 0.0f, 0.0001f);
|
|
||||||
assert_float_equal(sprites[0].uvMax[0], 0.25f, 0.0001f);
|
|
||||||
assert_float_equal(sprites[0].uvMax[1], 0.25f, 0.0001f);
|
|
||||||
|
|
||||||
// Top-middle edge stretches to fill the width minus both corners.
|
|
||||||
assert_float_equal(sprites[1].min[0], x + tileW, 0.0001f);
|
|
||||||
assert_float_equal(sprites[1].max[0], x + width - tileW, 0.0001f);
|
|
||||||
assert_float_equal(sprites[1].max[1], y + tileH, 0.0001f);
|
|
||||||
|
|
||||||
// Center tile fills the remaining interior on both axes.
|
|
||||||
assert_float_equal(sprites[4].min[0], x + tileW, 0.0001f);
|
|
||||||
assert_float_equal(sprites[4].min[1], y + tileH, 0.0001f);
|
|
||||||
assert_float_equal(sprites[4].max[0], x + width - tileW, 0.0001f);
|
|
||||||
assert_float_equal(sprites[4].max[1], y + height - tileH, 0.0001f);
|
|
||||||
|
|
||||||
// Bottom-right corner sits exactly at the rect's far corner, sampling
|
|
||||||
// tileset column 2, row 2.
|
|
||||||
assert_float_equal(sprites[8].min[0], x + width - tileW, 0.0001f);
|
|
||||||
assert_float_equal(sprites[8].min[1], y + height - tileH, 0.0001f);
|
|
||||||
assert_float_equal(sprites[8].max[0], x + width, 0.0001f);
|
|
||||||
assert_float_equal(sprites[8].max[1], y + height, 0.0001f);
|
|
||||||
assert_float_equal(sprites[8].uvMin[0], 0.5f, 0.0001f);
|
|
||||||
assert_float_equal(sprites[8].uvMin[1], 0.5f, 0.0001f);
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(int argc, char **argv) {
|
|
||||||
const struct CMUnitTest tests[] = {
|
|
||||||
cmocka_unit_test(test_uiFrameBuildSprites_layout),
|
|
||||||
};
|
|
||||||
|
|
||||||
return cmocka_run_group_tests(tests, NULL, NULL);
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "dusktest.h"
|
|
||||||
#include "ui/widget/uislider.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
|
|
||||||
// uiSliderInitFloat/InitInt require a real font (FONT_DEFAULT) to build
|
|
||||||
// the label's glyph cache, which needs a live GL context this test binary
|
|
||||||
// doesn't have -- see the label size caching contract for why this is
|
|
||||||
// safe to bypass here (uiSliderRebuildGeometry only reads slider->label's
|
|
||||||
// already-measured width/height, it never touches the font itself).
|
|
||||||
static void sliderSetupFloat(
|
|
||||||
uislider_t *slider,
|
|
||||||
const float_t value,
|
|
||||||
const float_t min,
|
|
||||||
const float_t max,
|
|
||||||
const float_t step
|
|
||||||
) {
|
|
||||||
memoryZero(slider, sizeof(uislider_t));
|
|
||||||
slider->label.width = 20;
|
|
||||||
slider->label.height = 10;
|
|
||||||
slider->type = UI_SLIDER_TYPE_FLOAT;
|
|
||||||
slider->min.f = min;
|
|
||||||
slider->max.f = max;
|
|
||||||
slider->step.f = step;
|
|
||||||
slider->value.f = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void sliderSetupInt(
|
|
||||||
uislider_t *slider,
|
|
||||||
const int32_t value,
|
|
||||||
const int32_t min,
|
|
||||||
const int32_t max,
|
|
||||||
const int32_t step
|
|
||||||
) {
|
|
||||||
memoryZero(slider, sizeof(uislider_t));
|
|
||||||
slider->label.width = 20;
|
|
||||||
slider->label.height = 10;
|
|
||||||
slider->type = UI_SLIDER_TYPE_INT;
|
|
||||||
slider->min.i = min;
|
|
||||||
slider->max.i = max;
|
|
||||||
slider->step.i = step;
|
|
||||||
slider->value.i = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_uiSliderRebuildGeometry_trackPosition(void **state) {
|
|
||||||
uislider_t slider;
|
|
||||||
sliderSetupFloat(&slider, 0.0f, 0.0f, 1.0f, 0.1f);
|
|
||||||
uiSliderRebuildGeometry(&slider);
|
|
||||||
|
|
||||||
const float_t expectedX = (float_t)slider.label.width + UI_SLIDER_GAP;
|
|
||||||
const float_t expectedY =
|
|
||||||
((float_t)slider.label.height - UI_SLIDER_TRACK_HEIGHT) * 0.5f;
|
|
||||||
|
|
||||||
assert_float_equal(slider.cachedTrack.min[0], expectedX, 0.0001f);
|
|
||||||
assert_float_equal(slider.cachedTrack.min[1], expectedY, 0.0001f);
|
|
||||||
assert_float_equal(
|
|
||||||
slider.cachedTrack.max[0], expectedX + UI_SLIDER_TRACK_WIDTH, 0.0001f
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_uiSliderRebuildGeometry_fillVisibility(void **state) {
|
|
||||||
uislider_t slider;
|
|
||||||
|
|
||||||
// At the minimum value, the ratio is 0 -- no fill should be visible.
|
|
||||||
sliderSetupFloat(&slider, 0.0f, 0.0f, 10.0f, 1.0f);
|
|
||||||
uiSliderRebuildGeometry(&slider);
|
|
||||||
assert_false(slider.cachedFillVisible);
|
|
||||||
|
|
||||||
// Halfway to max -- fill should cover half the track.
|
|
||||||
sliderSetupFloat(&slider, 5.0f, 0.0f, 10.0f, 1.0f);
|
|
||||||
uiSliderRebuildGeometry(&slider);
|
|
||||||
assert_true(slider.cachedFillVisible);
|
|
||||||
assert_float_equal(
|
|
||||||
slider.cachedFill.max[0] - slider.cachedFill.min[0],
|
|
||||||
UI_SLIDER_TRACK_WIDTH * 0.5f,
|
|
||||||
0.0001f
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_uiSliderRebuildGeometry_stepMarkers(void **state) {
|
|
||||||
uislider_t slider;
|
|
||||||
|
|
||||||
// 5 steps between 0 and 10 -- under the marker cap, so markers render.
|
|
||||||
sliderSetupInt(&slider, 0, 0, 10, 2);
|
|
||||||
uiSliderRebuildGeometry(&slider);
|
|
||||||
assert_int_equal(slider.cachedMarkerCount, 6);
|
|
||||||
assert_float_equal(
|
|
||||||
slider.cachedMarkers[0].min[0], slider.cachedTrack.min[0] -
|
|
||||||
UI_SLIDER_STEP_MARKER_WIDTH * 0.5f,
|
|
||||||
0.0001f
|
|
||||||
);
|
|
||||||
|
|
||||||
// 100 steps -- over the marker cap, so it falls back to a smooth fill
|
|
||||||
// with no markers.
|
|
||||||
sliderSetupInt(&slider, 0, 0, 100, 1);
|
|
||||||
uiSliderRebuildGeometry(&slider);
|
|
||||||
assert_int_equal(slider.cachedMarkerCount, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(int argc, char **argv) {
|
|
||||||
const struct CMUnitTest tests[] = {
|
|
||||||
cmocka_unit_test(test_uiSliderRebuildGeometry_trackPosition),
|
|
||||||
cmocka_unit_test(test_uiSliderRebuildGeometry_fillVisibility),
|
|
||||||
cmocka_unit_test(test_uiSliderRebuildGeometry_stepMarkers),
|
|
||||||
};
|
|
||||||
|
|
||||||
return cmocka_run_group_tests(tests, NULL, NULL);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "dusktest.h"
|
|
||||||
#include "ui/widget/uitab.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
|
|
||||||
// uiTabInit requires a real font (FONT_DEFAULT) to build the label's
|
|
||||||
// glyph cache, which needs a live GL context this test binary doesn't
|
|
||||||
// have. uiTabRebuildBackground only reads the label's already-measured
|
|
||||||
// width/height, so it's safe to set those directly and exercise it here.
|
|
||||||
static void test_uiTabRebuildBackground_matchesLabelSize(void **state) {
|
|
||||||
uitab_t tab;
|
|
||||||
memoryZero(&tab, sizeof(uitab_t));
|
|
||||||
tab.label.width = 42;
|
|
||||||
tab.label.height = 12;
|
|
||||||
|
|
||||||
uiTabRebuildBackground(&tab);
|
|
||||||
|
|
||||||
assert_float_equal(tab.cachedBackground.min[0], 0.0f, 0.0001f);
|
|
||||||
assert_float_equal(tab.cachedBackground.min[1], 0.0f, 0.0001f);
|
|
||||||
assert_float_equal(tab.cachedBackground.max[0], 42.0f, 0.0001f);
|
|
||||||
assert_float_equal(tab.cachedBackground.max[1], 12.0f, 0.0001f);
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(int argc, char **argv) {
|
|
||||||
const struct CMUnitTest tests[] = {
|
|
||||||
cmocka_unit_test(test_uiTabRebuildBackground_matchesLabelSize),
|
|
||||||
};
|
|
||||||
|
|
||||||
return cmocka_run_group_tests(tests, NULL, NULL);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
var Actions;
|
||||||
|
var camera, cameraPosition;
|
||||||
|
var cube, cubePosition, cubeRenderable, cubeMesh;
|
||||||
|
var ground, groundPosition, groundRenderable;
|
||||||
|
|
||||||
|
// init() is called via scriptManagerCallGlobal(), which pumps the asset
|
||||||
|
// system + job queue until any promise it returns settles - so it's safe
|
||||||
|
// to await include() here even though this runs before the main loop.
|
||||||
|
async function init() {
|
||||||
|
Actions = await include("input.js");
|
||||||
|
|
||||||
|
camera = new Entity();
|
||||||
|
cameraPosition = camera.add(POSITION);
|
||||||
|
camera.add(CAMERA);
|
||||||
|
cameraPosition.position = new Vec3(3, 3, -6);
|
||||||
|
cameraPosition.lookAt(new Vec3(0, 0, 0));
|
||||||
|
|
||||||
|
cube = new Entity();
|
||||||
|
cubePosition = cube.add(POSITION);
|
||||||
|
cubeRenderable = cube.add(RENDERABLE);
|
||||||
|
cubeMesh = Mesh.createCube();
|
||||||
|
cubeRenderable.mesh = cubeMesh;
|
||||||
|
cubeRenderable.color = Color.red();
|
||||||
|
|
||||||
|
ground = new Entity();
|
||||||
|
groundPosition = ground.add(POSITION);
|
||||||
|
groundRenderable = ground.add(RENDERABLE);
|
||||||
|
groundRenderable.mesh = Mesh.createCube();
|
||||||
|
groundRenderable.color = Color.dark_gray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs every frame (including dynamic/interpolation frames) - use for
|
||||||
|
// smooth, purely presentational animation.
|
||||||
|
function update() {
|
||||||
|
cubePosition.rotation.y += TIME.delta * 1.5;
|
||||||
|
cubePosition.rotation.x += TIME.delta * 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs once per fixed timestep only - use for gameplay logic that should
|
||||||
|
// be deterministic and independent of display refresh rate.
|
||||||
|
function fixedUpdate() {
|
||||||
|
}
|
||||||
|
|
||||||
|
function deinit() {
|
||||||
|
cube.dispose();
|
||||||
|
camera.dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Binds physical buttons to abstract actions, then exports the action
|
||||||
|
// constants so other scripts don't need to know raw INPUT_ACTION_* names.
|
||||||
|
Input.bind("w", INPUT_ACTION_UP);
|
||||||
|
Input.bind("s", INPUT_ACTION_DOWN);
|
||||||
|
Input.bind("a", INPUT_ACTION_LEFT);
|
||||||
|
Input.bind("d", INPUT_ACTION_RIGHT);
|
||||||
|
Input.bind("space", INPUT_ACTION_ACCEPT);
|
||||||
|
Input.bind("escape", INPUT_ACTION_RAGEQUIT);
|
||||||
|
|
||||||
|
if(typeof INPUT_GAMEPAD !== "undefined") {
|
||||||
|
Input.bind("gamepad_up", INPUT_ACTION_UP);
|
||||||
|
Input.bind("gamepad_down", INPUT_ACTION_DOWN);
|
||||||
|
Input.bind("gamepad_left", INPUT_ACTION_LEFT);
|
||||||
|
Input.bind("gamepad_right", INPUT_ACTION_RIGHT);
|
||||||
|
Input.bind("gamepad_a", INPUT_ACTION_ACCEPT);
|
||||||
|
Input.bind("gamepad_start", INPUT_ACTION_RAGEQUIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
module = {
|
||||||
|
UP: INPUT_ACTION_UP,
|
||||||
|
DOWN: INPUT_ACTION_DOWN,
|
||||||
|
LEFT: INPUT_ACTION_LEFT,
|
||||||
|
RIGHT: INPUT_ACTION_RIGHT,
|
||||||
|
ACCEPT: INPUT_ACTION_ACCEPT,
|
||||||
|
CANCEL: INPUT_ACTION_CANCEL,
|
||||||
|
RAGEQUIT: INPUT_ACTION_RAGEQUIT
|
||||||
|
};
|
||||||
+2
-66
@@ -1,66 +1,2 @@
|
|||||||
msgid ""
|
msgid "test.string"
|
||||||
msgstr ""
|
msgstr "This is a test string"
|
||||||
"Project-Id-Version: ExampleApp 1.0\n"
|
|
||||||
"Language: en\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : n==2 ? 1 : (n<7 ? 2 : 3));\n"
|
|
||||||
|
|
||||||
#: ui/menu.c:10
|
|
||||||
msgid "ui.title"
|
|
||||||
msgstr ""
|
|
||||||
"Welcome"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.general"
|
|
||||||
msgstr "General"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.input"
|
|
||||||
msgstr "Input"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.display"
|
|
||||||
msgstr "Display"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.audio"
|
|
||||||
msgstr "Audio"
|
|
||||||
|
|
||||||
msgid "ui.settings.input.deadzone"
|
|
||||||
msgstr "Deadzone"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
|
|
||||||
msgid "ui.settings.general.language"
|
|
||||||
msgstr "Language"
|
|
||||||
|
|
||||||
msgid "ui.settings.general.language_detail"
|
|
||||||
msgstr "Takes effect after restarting the application."
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.apply"
|
|
||||||
msgstr "Apply"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/uiconfirm.c
|
|
||||||
msgid "ui.confirm.discard_changes"
|
|
||||||
msgstr "Discard unsaved changes?"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
|
||||||
msgid "ui.game_menu.characters"
|
|
||||||
msgstr "Characters"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
|
||||||
msgid "ui.game_menu.items"
|
|
||||||
msgstr "Items"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
|
||||||
msgid "ui.game_menu.settings"
|
|
||||||
msgstr "Settings"
|
|
||||||
|
|
||||||
msgid "item.potion.name"
|
|
||||||
msgstr "Potion"
|
|
||||||
|
|
||||||
msgid "item.potato.name"
|
|
||||||
msgstr "Potato"
|
|
||||||
|
|
||||||
msgid "item.apple.name"
|
|
||||||
msgstr "Apple"
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: ExampleApp 1.0\n"
|
|
||||||
"Language: es\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n==1 ? 0 : 1);\n"
|
|
||||||
|
|
||||||
#: ui/menu.c:10
|
|
||||||
msgid "ui.title"
|
|
||||||
msgstr ""
|
|
||||||
"Bienvenido"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.general"
|
|
||||||
msgstr "General"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.input"
|
|
||||||
msgstr "Entrada"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.display"
|
|
||||||
msgstr "Pantalla"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.audio"
|
|
||||||
msgstr "Audio"
|
|
||||||
|
|
||||||
msgid "ui.settings.input.deadzone"
|
|
||||||
msgstr "Deadzone"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
|
|
||||||
msgid "ui.settings.general.language"
|
|
||||||
msgstr "Idioma"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
|
|
||||||
msgid "ui.settings.general.language_detail"
|
|
||||||
msgstr "Se aplica después de reiniciar la aplicación."
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.apply"
|
|
||||||
msgstr "Aplicar"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/uiconfirm.c
|
|
||||||
msgid "ui.confirm.discard_changes"
|
|
||||||
msgstr "¿Descartar los cambios no guardados?"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
|
||||||
msgid "ui.game_menu.characters"
|
|
||||||
msgstr "Personajes"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
|
||||||
msgid "ui.game_menu.items"
|
|
||||||
msgstr "Objetos"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
|
||||||
msgid "ui.game_menu.settings"
|
|
||||||
msgstr "Configuración"
|
|
||||||
|
|
||||||
#: src/dusk/rpg/item/item.json
|
|
||||||
msgid "item.potion.name"
|
|
||||||
msgstr "Poción"
|
|
||||||
|
|
||||||
#: src/dusk/rpg/item/item.json
|
|
||||||
msgid "item.potato.name"
|
|
||||||
msgstr "Papa"
|
|
||||||
|
|
||||||
#: src/dusk/rpg/item/item.json
|
|
||||||
msgid "item.apple.name"
|
|
||||||
msgstr "Manzana"
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: ExampleApp 1.0\n"
|
|
||||||
"Language: ja\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Plural-Forms: nplurals=1; plural=(0);\n"
|
|
||||||
|
|
||||||
#: ui/menu.c:10
|
|
||||||
msgid "ui.title"
|
|
||||||
msgstr ""
|
|
||||||
"歓迎"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.general"
|
|
||||||
msgstr "一般"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.input"
|
|
||||||
msgstr "入力"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.display"
|
|
||||||
msgstr "表示"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.audio"
|
|
||||||
msgstr "オーディオ"
|
|
||||||
|
|
||||||
msgid "ui.settings.input.deadzone"
|
|
||||||
msgstr "デッドゾーン"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
|
|
||||||
msgid "ui.settings.general.language"
|
|
||||||
msgstr "言語"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
|
|
||||||
msgid "ui.settings.general.language_detail"
|
|
||||||
msgstr "アプリケーションを再起動すると適用されます。"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.apply"
|
|
||||||
msgstr "適用"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/uiconfirm.c
|
|
||||||
msgid "ui.confirm.discard_changes"
|
|
||||||
msgstr "未保存の変更を破棄しますか?"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
|
||||||
msgid "ui.game_menu.characters"
|
|
||||||
msgstr "キャラクター"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
|
||||||
msgid "ui.game_menu.items"
|
|
||||||
msgstr "アイテム"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
|
||||||
msgid "ui.game_menu.settings"
|
|
||||||
msgstr "設定"
|
|
||||||
|
|
||||||
#: src/dusk/rpg/item/item.json
|
|
||||||
msgid "item.potion.name"
|
|
||||||
msgstr "ポーション"
|
|
||||||
|
|
||||||
#: src/dusk/rpg/item/item.json
|
|
||||||
msgid "item.potato.name"
|
|
||||||
msgstr "ジャガイモ"
|
|
||||||
|
|
||||||
#: src/dusk/rpg/item/item.json
|
|
||||||
msgid "item.apple.name"
|
|
||||||
msgstr "リンゴ"
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
// Copyright (c) 2026 Dominic Masters
|
|
||||||
//
|
|
||||||
// This software is released under the MIT License.
|
|
||||||
// https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
// Gamepad/keyboard focus-navigation, replacing the old global uifocus_t
|
|
||||||
// stack. A Menu's children still render/update through the normal tree
|
|
||||||
// (add()), but navigation uses its own `items` list -- addItem() calls
|
|
||||||
// add() and also tracks the widget as focusable, so decorative elements
|
|
||||||
// (a title Label, a Rectangle background) just use add() directly and
|
|
||||||
// never enter navigation.
|
|
||||||
//
|
|
||||||
// open()/close() push/pop this Menu on a small global stack -- only the
|
|
||||||
// topmost entry consumes CANCEL/ACCEPT/direction input each tick, so a
|
|
||||||
// Menu opened on top of another (e.g. a Settings sub-page menu, or a
|
|
||||||
// Confirm dialog) doesn't fight it for input; everything underneath
|
|
||||||
// keeps rendering/updating, just doesn't react to input until it's
|
|
||||||
// topmost again.
|
|
||||||
class Menu extends UIElement {
|
|
||||||
init() {
|
|
||||||
this.items = [];
|
|
||||||
this.cols = 1;
|
|
||||||
this.index = 0;
|
|
||||||
|
|
||||||
this.onSelected = null;
|
|
||||||
this.onChanged = null;
|
|
||||||
this.onClosed = null;
|
|
||||||
|
|
||||||
this._heldDir = null;
|
|
||||||
this._heldTime = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
addItem(widget) {
|
|
||||||
this.add(widget);
|
|
||||||
this.items.push(widget);
|
|
||||||
return widget;
|
|
||||||
}
|
|
||||||
|
|
||||||
open() {
|
|
||||||
if(Menu._stack.indexOf(this) !== -1) return;
|
|
||||||
Menu._stack.push(this);
|
|
||||||
|
|
||||||
const item = this.items[this.index];
|
|
||||||
if(item && item.setHighlighted) item.setHighlighted(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Closes this Menu and anything opened on top of it, matching the old
|
|
||||||
// uifocus_t's uiFocusPopItem (popping a mid-stack item also pops
|
|
||||||
// everything above it).
|
|
||||||
close() {
|
|
||||||
const idx = Menu._stack.indexOf(this);
|
|
||||||
if(idx === -1) return;
|
|
||||||
|
|
||||||
while(Menu._stack.length > idx) {
|
|
||||||
Menu._stack.pop()._onClosed();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safety net: dispose() has no override-preserving trampoline the way
|
|
||||||
// render()/update() do (see UIElement's dispose() docs), so a bare
|
|
||||||
// this.menu.dispose() would leave a disposed instance stuck at the top
|
|
||||||
// of Menu._stack forever if the caller forgot to close() first --
|
|
||||||
// always close() (a no-op if never opened) before the native cascade.
|
|
||||||
dispose() {
|
|
||||||
this.close();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
_onClosed() {
|
|
||||||
for(let i = 0; i < this.items.length; i++) {
|
|
||||||
const item = this.items[i];
|
|
||||||
if(item.setHighlighted) item.setHighlighted(false);
|
|
||||||
}
|
|
||||||
if(this.onClosed) this.onClosed();
|
|
||||||
}
|
|
||||||
|
|
||||||
update() {
|
|
||||||
this.updateChildren();
|
|
||||||
|
|
||||||
if(Menu.top() !== this) return;
|
|
||||||
|
|
||||||
if(Input.pressed(InputBind.CANCEL)) {
|
|
||||||
this.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(Input.pressed(InputBind.ACCEPT) && this.onSelected) {
|
|
||||||
this.onSelected(this.index, this.items[this.index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._handleDirection();
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleDirection() {
|
|
||||||
const dir = this._currentDirection();
|
|
||||||
|
|
||||||
if(!dir) {
|
|
||||||
this._heldDir = null;
|
|
||||||
this._heldTime = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(dir !== this._heldDir) {
|
|
||||||
this._heldDir = dir;
|
|
||||||
this._heldTime = 0;
|
|
||||||
this._moveDirection(dir.dx, dir.dy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._heldTime += Time.delta;
|
|
||||||
if(this._heldTime >= Menu.HOLD_DELAY) {
|
|
||||||
this._heldTime -= Menu.HOLD_REPEAT;
|
|
||||||
this._moveDirection(dir.dx, dir.dy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Identity-stable direction markers so _handleDirection's `dir !==
|
|
||||||
// this._heldDir` check works without allocating a fresh object (and
|
|
||||||
// therefore never matching) every tick.
|
|
||||||
_currentDirection() {
|
|
||||||
if(Input.isDown(InputBind.LEFT)) return Menu.DIR_LEFT;
|
|
||||||
if(Input.isDown(InputBind.RIGHT)) return Menu.DIR_RIGHT;
|
|
||||||
if(Input.isDown(InputBind.UP)) return Menu.DIR_UP;
|
|
||||||
if(Input.isDown(InputBind.DOWN)) return Menu.DIR_DOWN;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gives the currently-highlighted item first refusal (a Slider/
|
|
||||||
// Dropdown/Checkbox consuming LEFT/RIGHT itself), falling back to
|
|
||||||
// moving the cursor -- mirrors uifocus_t's per-item `direction`
|
|
||||||
// callback.
|
|
||||||
_moveDirection(dx, dy) {
|
|
||||||
if(this.items.length === 0) return;
|
|
||||||
|
|
||||||
const item = this.items[this.index];
|
|
||||||
if(item && typeof item.directionInput === 'function') {
|
|
||||||
if(item.directionInput(dx, dy)) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._moveIndex(dx, dy);
|
|
||||||
}
|
|
||||||
|
|
||||||
_moveIndex(dx, dy) {
|
|
||||||
const count = this.items.length;
|
|
||||||
if(count === 0) return;
|
|
||||||
|
|
||||||
const cols = Math.max(1, this.cols);
|
|
||||||
const rows = Math.ceil(count / cols);
|
|
||||||
|
|
||||||
const col = ((this.index % cols) + dx + cols) % cols;
|
|
||||||
const row = ((Math.floor(this.index / cols)) + dy + rows) % rows;
|
|
||||||
|
|
||||||
let next = row * cols + col;
|
|
||||||
if(next >= count) next = count - 1;
|
|
||||||
|
|
||||||
this._setIndex(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
_setIndex(next) {
|
|
||||||
if(next === this.index) return;
|
|
||||||
|
|
||||||
const oldItem = this.items[this.index];
|
|
||||||
if(oldItem && oldItem.setHighlighted) oldItem.setHighlighted(false);
|
|
||||||
|
|
||||||
this.index = next;
|
|
||||||
|
|
||||||
const newItem = this.items[this.index];
|
|
||||||
if(newItem && newItem.setHighlighted) newItem.setHighlighted(true);
|
|
||||||
|
|
||||||
if(this.onChanged) this.onChanged(this.index, newItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Menu._stack = [];
|
|
||||||
Menu.top = function() {
|
|
||||||
return Menu._stack.length > 0 ? Menu._stack[Menu._stack.length - 1] : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
Menu.HOLD_DELAY = 0.5;
|
|
||||||
Menu.HOLD_REPEAT = 0.1;
|
|
||||||
|
|
||||||
Menu.DIR_LEFT = { dx: -1, dy: 0 };
|
|
||||||
Menu.DIR_RIGHT = { dx: 1, dy: 0 };
|
|
||||||
Menu.DIR_UP = { dx: 0, dy: -1 };
|
|
||||||
Menu.DIR_DOWN = { dx: 0, dy: 1 };
|
|
||||||
|
|
||||||
module.exports = Menu;
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
var Player = require('./Player.js');
|
|
||||||
var PlayerCamera = require('./PlayerCamera.js');
|
|
||||||
var TestPlane = require('./TestPlane.js');
|
|
||||||
var TestUIElement = require('./TestUIElement.js');
|
|
||||||
var FpsCounter = require('./FpsCounter.js');
|
|
||||||
var ConsoleOverlay = require('./ConsoleOverlay.js');
|
|
||||||
|
|
||||||
class OverworldScene {
|
|
||||||
constructor() {
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.player = new Player();
|
|
||||||
this.plane = new TestPlane();
|
|
||||||
this.camera = new PlayerCamera(this.player);
|
|
||||||
|
|
||||||
this.testUi = new TestUIElement();
|
|
||||||
UI.add(this.testUi);
|
|
||||||
|
|
||||||
this.fpsCounter = new FpsCounter();
|
|
||||||
UI.addOverlay(this.fpsCounter);
|
|
||||||
|
|
||||||
this.consoleOverlay = new ConsoleOverlay();
|
|
||||||
UI.addOverlay(this.consoleOverlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
lateUpdate() {
|
|
||||||
this.camera.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this.camera.dispose();
|
|
||||||
this.player.dispose();
|
|
||||||
this.plane.dispose();
|
|
||||||
|
|
||||||
// testUi.dispose() cascades to its background/label children and
|
|
||||||
// removes itself from UI's render roots -- no separate UI.remove()
|
|
||||||
// needed.
|
|
||||||
this.testUi.dispose();
|
|
||||||
this.fpsCounter.dispose();
|
|
||||||
this.consoleOverlay.dispose();
|
|
||||||
|
|
||||||
this.camera = null;
|
|
||||||
this.player = null;
|
|
||||||
this.plane = null;
|
|
||||||
this.testUi = null;
|
|
||||||
this.fpsCounter = null;
|
|
||||||
this.consoleOverlay = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = OverworldScene;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// Copyright (c) 2026 Dominic Masters
|
|
||||||
//
|
|
||||||
// This software is released under the MIT License.
|
|
||||||
// https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
class Player extends Entity {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.position = this.add(POSITION);
|
|
||||||
this.position.setLocalPosition(0.0, 2.0, 0.0);
|
|
||||||
|
|
||||||
this.physics = this.add(PHYSICS);
|
|
||||||
this.physics.setShape(PHYSICS_SHAPE_CAPSULE, 0.5, 0.5);
|
|
||||||
|
|
||||||
this.renderable = this.add(RENDERABLE);
|
|
||||||
this.renderable.setMesh(0, MESH_CAPSULE);
|
|
||||||
this.renderable.setColor(0, 0, 255, 255);
|
|
||||||
|
|
||||||
this.add(PLAYER);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = Player;
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
var CAMERA_OFFSET_ANGLE = 0.0;
|
|
||||||
var CAMERA_OFFSET_RADIUS = 18.0;
|
|
||||||
var CAMERA_OFFSET_HEIGHT = 10.0;
|
|
||||||
|
|
||||||
class PlayerCamera extends Entity {
|
|
||||||
constructor(target) {
|
|
||||||
super();
|
|
||||||
this.target = target;
|
|
||||||
|
|
||||||
this.position = this.add(POSITION);
|
|
||||||
this.add(CAMERA);
|
|
||||||
|
|
||||||
this.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
update() {
|
|
||||||
var targetPosition = this.target.position.getLocalPosition();
|
|
||||||
|
|
||||||
var eyeX = (
|
|
||||||
targetPosition.x + Math.cos(CAMERA_OFFSET_ANGLE) * CAMERA_OFFSET_RADIUS
|
|
||||||
);
|
|
||||||
var eyeY = (
|
|
||||||
targetPosition.y + CAMERA_OFFSET_HEIGHT
|
|
||||||
);
|
|
||||||
var eyeZ = (
|
|
||||||
targetPosition.z + Math.sin(CAMERA_OFFSET_ANGLE) * CAMERA_OFFSET_RADIUS
|
|
||||||
);
|
|
||||||
|
|
||||||
this.position.lookAt(
|
|
||||||
eyeX, eyeY, eyeZ,
|
|
||||||
targetPosition.x, targetPosition.y, targetPosition.z,
|
|
||||||
0.0, 1.0, 0.0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this.target = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = PlayerCamera;
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
class TestPlane extends Entity {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.position = this.add(POSITION);
|
|
||||||
this.position.setLocalPosition(-10.0, 0.0, -10.0);
|
|
||||||
this.position.setLocalScale(20.0, 1.0, 20.0);
|
|
||||||
|
|
||||||
this.physics = this.add(PHYSICS);
|
|
||||||
this.physics.setBodyType(PHYSICS_BODY_STATIC);
|
|
||||||
this.physics.setShape(PHYSICS_SHAPE_PLANE, 0.0, 1.0, 0.0, 0.0);
|
|
||||||
|
|
||||||
this.renderable = this.add(RENDERABLE);
|
|
||||||
this.renderable.setMesh(0, MESH_PLANE);
|
|
||||||
this.renderable.setColor(128, 128, 128, 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = TestPlane;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// Copyright (c) 2026 Dominic Masters
|
|
||||||
//
|
|
||||||
// This software is released under the MIT License.
|
|
||||||
// https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
var Button = require('./Button.js');
|
|
||||||
var Menu = require('./Menu.js');
|
|
||||||
var Settings = require('./Settings.js');
|
|
||||||
|
|
||||||
// No render() override -- UIElement's base render() auto-renders whatever
|
|
||||||
// was added() here, in add-order: background first, labels, then the
|
|
||||||
// menu (and its two buttons) on top of it.
|
|
||||||
class TestUIElement extends UIElement {
|
|
||||||
init() {
|
|
||||||
this.background = new Rectangle();
|
|
||||||
this.background.width = 220;
|
|
||||||
this.background.height = 128;
|
|
||||||
this.background.color = { r: 255, g: 0, b: 0, a: 255 };
|
|
||||||
this.add(this.background);
|
|
||||||
|
|
||||||
this.label = new Label();
|
|
||||||
this.label.text = 'Hello World!';
|
|
||||||
this.label.x = 8;
|
|
||||||
this.label.y = 8;
|
|
||||||
this.add(this.label);
|
|
||||||
|
|
||||||
this.label2 = new Label();
|
|
||||||
this.label2.text = 'Second label!';
|
|
||||||
this.label2.x = 8;
|
|
||||||
this.label2.y = 24;
|
|
||||||
this.add(this.label2);
|
|
||||||
|
|
||||||
// A navigable 2-item Menu -- UP/DOWN moves the highlighted cursor
|
|
||||||
// between the buttons, ACCEPT fires whichever one is highlighted.
|
|
||||||
this.menu = new Menu();
|
|
||||||
this.add(this.menu);
|
|
||||||
|
|
||||||
this.helloButton = new Button();
|
|
||||||
this.helloButton.text = 'Say hello';
|
|
||||||
this.helloButton.x = 8;
|
|
||||||
this.helloButton.y = 48;
|
|
||||||
this.helloButton.onSelect = () => Console.print('Hello!');
|
|
||||||
this.menu.addItem(this.helloButton);
|
|
||||||
|
|
||||||
this.settingsButton = new Button();
|
|
||||||
this.settingsButton.text = 'Open settings';
|
|
||||||
this.settingsButton.x = 8;
|
|
||||||
this.settingsButton.y = 84;
|
|
||||||
this.settingsButton.onSelect = () => new Settings().open();
|
|
||||||
this.menu.addItem(this.settingsButton);
|
|
||||||
|
|
||||||
this.menu.open();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = TestUIElement;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
require('./input.js');
|
|
||||||
|
|
||||||
var OverworldScene = require('./OverworldScene.js');
|
|
||||||
|
|
||||||
Scene.set(new OverworldScene());
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// Copyright (c) 2026 Dominic Masters
|
|
||||||
//
|
|
||||||
// This software is released under the MIT License.
|
|
||||||
// https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
// Default input bindings, ported from the old
|
|
||||||
// src/duskrpg/input/inputbindmap.h (removed) -- one INPUT_BIND_MAP per
|
|
||||||
// compile-time target became one branch here on Platform.current.
|
|
||||||
|
|
||||||
function bindAll(pairs) {
|
|
||||||
for(var i = 0; i < pairs.length; i++) {
|
|
||||||
Input.bind(pairs[i][0], pairs[i][1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if(Platform.current === 'linux' || Platform.current === 'knulli') {
|
|
||||||
bindAll([
|
|
||||||
// Keyboard
|
|
||||||
[InputButton.W, InputBind.UP],
|
|
||||||
[InputButton.S, InputBind.DOWN],
|
|
||||||
[InputButton.A, InputBind.LEFT],
|
|
||||||
[InputButton.D, InputBind.RIGHT],
|
|
||||||
[InputButton.LEFT, InputBind.LEFT],
|
|
||||||
[InputButton.RIGHT, InputBind.RIGHT],
|
|
||||||
[InputButton.UP, InputBind.UP],
|
|
||||||
[InputButton.DOWN, InputBind.DOWN],
|
|
||||||
[InputButton.ENTER, InputBind.ACCEPT],
|
|
||||||
[InputButton.E, InputBind.ACCEPT],
|
|
||||||
[InputButton.SPACE, InputBind.ACCEPT],
|
|
||||||
[InputButton.TAB, InputBind.CANCEL],
|
|
||||||
[InputButton.Q, InputBind.CANCEL],
|
|
||||||
[InputButton.ESCAPE, InputBind.RAGEQUIT],
|
|
||||||
[InputButton.ENTER, InputBind.PAUSE],
|
|
||||||
['`', InputBind.CONSOLE],
|
|
||||||
|
|
||||||
// Gamepad
|
|
||||||
[InputButton.GAMEPAD_UP, InputBind.UP],
|
|
||||||
[InputButton.GAMEPAD_DOWN, InputBind.DOWN],
|
|
||||||
[InputButton.GAMEPAD_LEFT, InputBind.LEFT],
|
|
||||||
[InputButton.GAMEPAD_RIGHT, InputBind.RIGHT],
|
|
||||||
[InputButton.GAMEPAD_A, InputBind.ACCEPT],
|
|
||||||
[InputButton.GAMEPAD_B, InputBind.CANCEL],
|
|
||||||
[InputButton.GAMEPAD_BACK, InputBind.RAGEQUIT],
|
|
||||||
[InputButton.GAMEPAD_START, InputBind.PAUSE],
|
|
||||||
[InputButton.GAMEPAD_LSTICK_UP, InputBind.UP],
|
|
||||||
[InputButton.GAMEPAD_LSTICK_DOWN, InputBind.DOWN],
|
|
||||||
[InputButton.GAMEPAD_LSTICK_LEFT, InputBind.LEFT],
|
|
||||||
[InputButton.GAMEPAD_LSTICK_RIGHT, InputBind.RIGHT],
|
|
||||||
|
|
||||||
// Mouse
|
|
||||||
[InputButton.MOUSE_X, InputBind.POINTERX],
|
|
||||||
[InputButton.MOUSE_Y, InputBind.POINTERY],
|
|
||||||
]);
|
|
||||||
} else if(Platform.current === 'psp') {
|
|
||||||
bindAll([
|
|
||||||
[InputButton.UP, InputBind.UP],
|
|
||||||
[InputButton.DOWN, InputBind.DOWN],
|
|
||||||
[InputButton.LEFT, InputBind.LEFT],
|
|
||||||
[InputButton.RIGHT, InputBind.RIGHT],
|
|
||||||
[InputButton.ACCEPT, InputBind.ACCEPT],
|
|
||||||
[InputButton.CANCEL, InputBind.CANCEL],
|
|
||||||
[InputButton.TRIANGLE, InputBind.CONSOLE],
|
|
||||||
[InputButton.SELECT, InputBind.RAGEQUIT],
|
|
||||||
[InputButton.START, InputBind.PAUSE],
|
|
||||||
[InputButton.LSTICK_UP, InputBind.UP],
|
|
||||||
[InputButton.LSTICK_DOWN, InputBind.DOWN],
|
|
||||||
[InputButton.LSTICK_LEFT, InputBind.LEFT],
|
|
||||||
[InputButton.LSTICK_RIGHT, InputBind.RIGHT],
|
|
||||||
]);
|
|
||||||
} else if(Platform.current === 'gamecube' || Platform.current === 'wii') {
|
|
||||||
// GameCube and Wii share the same pad layout for now.
|
|
||||||
// TODO: Wiimote, USB Keyboard, probably more.
|
|
||||||
bindAll([
|
|
||||||
[InputButton.UP, InputBind.UP],
|
|
||||||
[InputButton.DOWN, InputBind.DOWN],
|
|
||||||
[InputButton.LEFT, InputBind.LEFT],
|
|
||||||
[InputButton.RIGHT, InputBind.RIGHT],
|
|
||||||
[InputButton.LSTICK_UP, InputBind.UP],
|
|
||||||
[InputButton.LSTICK_DOWN, InputBind.DOWN],
|
|
||||||
[InputButton.LSTICK_LEFT, InputBind.LEFT],
|
|
||||||
[InputButton.LSTICK_RIGHT, InputBind.RIGHT],
|
|
||||||
[InputButton.A, InputBind.ACCEPT],
|
|
||||||
[InputButton.B, InputBind.CANCEL],
|
|
||||||
[InputButton.Z, InputBind.CONSOLE],
|
|
||||||
[InputButton.START, InputBind.RAGEQUIT],
|
|
||||||
[InputButton.START, InputBind.PAUSE],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,5 +1,5 @@
|
|||||||
# Copyright (c) 2026 Dominic Masters
|
# Copyright (c) 2026 Dominic Masters
|
||||||
#
|
#
|
||||||
# This software is released under the MIT License.
|
# This software is released under the MIT License.
|
||||||
# https://opensource.org/licenses/MIT
|
# https://opensource.org/licenses/MIT
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
|||||||
|
|
||||||
# Link libraries
|
# Link libraries
|
||||||
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
|
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
|
||||||
bba
|
# bba
|
||||||
)
|
)
|
||||||
|
|
||||||
# ISO post-build: produce NTSC-J, NTSC-U and PAL disc images
|
# ISO post-build: produce NTSC-J, NTSC-U and PAL disc images
|
||||||
|
|||||||
@@ -43,4 +43,5 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
|||||||
DUSK_INPUT_POINTER
|
DUSK_INPUT_POINTER
|
||||||
DUSK_INPUT_GAMEPAD
|
DUSK_INPUT_GAMEPAD
|
||||||
DUSK_TIME_DYNAMIC
|
DUSK_TIME_DYNAMIC
|
||||||
|
DUSK_THREAD_PTHREAD
|
||||||
)
|
)
|
||||||
@@ -32,6 +32,7 @@ set(DUSK_BACKTRACE ON CACHE BOOL "Enable backtrace support for assert failures."
|
|||||||
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
||||||
DUSK_SDL2
|
DUSK_SDL2
|
||||||
DUSK_OPENGL
|
DUSK_OPENGL
|
||||||
|
DUSK_CONSOLE_POSIX
|
||||||
# DUSK_OPENGL_LEGACY
|
# DUSK_OPENGL_LEGACY
|
||||||
DUSK_LINUX
|
DUSK_LINUX
|
||||||
DUSK_DISPLAY_SIZE_DYNAMIC
|
DUSK_DISPLAY_SIZE_DYNAMIC
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
if(NOT CMAKE_BUILD_TYPE)
|
|
||||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
set(CMAKE_AR "$ENV{PSPDEV}/bin/psp-ar" CACHE FILEPATH "" FORCE)
|
set(CMAKE_AR "$ENV{PSPDEV}/bin/psp-ar" CACHE FILEPATH "" FORCE)
|
||||||
set(CMAKE_RANLIB "$ENV{PSPDEV}/bin/psp-ranlib" CACHE FILEPATH "" FORCE)
|
set(CMAKE_RANLIB "$ENV{PSPDEV}/bin/psp-ranlib" CACHE FILEPATH "" FORCE)
|
||||||
set(CMAKE_C_COMPILER_AR "$ENV{PSPDEV}/bin/psp-ar" CACHE FILEPATH "" FORCE)
|
set(CMAKE_C_COMPILER_AR "$ENV{PSPDEV}/bin/psp-ar" CACHE FILEPATH "" FORCE)
|
||||||
@@ -59,9 +55,16 @@ target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
|
|||||||
DUSK_DISPLAY_WIDTH=480
|
DUSK_DISPLAY_WIDTH=480
|
||||||
DUSK_DISPLAY_HEIGHT=272
|
DUSK_DISPLAY_HEIGHT=272
|
||||||
DUSK_THREAD_PTHREAD
|
DUSK_THREAD_PTHREAD
|
||||||
|
DUSK_TIME_DYNAMIC
|
||||||
DUSK_DISPLAY_OVERSCAN=6
|
DUSK_DISPLAY_OVERSCAN=6
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
|
||||||
|
target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
|
||||||
|
DUSK_ASSERTIONS_FAKED
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Postbuild, create .pbp file for PSP.
|
# Postbuild, create .pbp file for PSP.
|
||||||
create_pbp_file(
|
create_pbp_file(
|
||||||
TARGET "${DUSK_BINARY_TARGET_NAME}"
|
TARGET "${DUSK_BINARY_TARGET_NAME}"
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Copyright (c) 2026 Dominic Masters
|
||||||
|
#
|
||||||
|
# This software is released under the MIT License.
|
||||||
|
# https://opensource.org/licenses/MIT
|
||||||
|
|
||||||
|
if(NOT DEFINED ENV{VITASDK})
|
||||||
|
message(FATAL_ERROR "VITASDK environment variable is not set.")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include("$ENV{VITASDK}/share/vita.cmake" REQUIRED)
|
||||||
|
|
||||||
|
set(VITA_APP_NAME "Dusk")
|
||||||
|
set(VITA_TITLEID "DUSK00001")
|
||||||
|
set(VITA_VERSION "01.00")
|
||||||
|
|
||||||
|
find_package(SDL2 REQUIRED)
|
||||||
|
|
||||||
|
# Custom flags for cglm
|
||||||
|
set(CGLM_SHARED OFF CACHE BOOL "Build cglm shared" FORCE)
|
||||||
|
set(CGLM_STATIC ON CACHE BOOL "Build cglm static" FORCE)
|
||||||
|
find_package(cglm REQUIRED)
|
||||||
|
|
||||||
|
# Link libraries
|
||||||
|
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
||||||
|
${SDL2_LIBRARIES}
|
||||||
|
cglm
|
||||||
|
SDL2
|
||||||
|
SDL2main
|
||||||
|
zip
|
||||||
|
bz2
|
||||||
|
z
|
||||||
|
zstd
|
||||||
|
crypto
|
||||||
|
lzma
|
||||||
|
m
|
||||||
|
pthread
|
||||||
|
stdc++
|
||||||
|
vitaGL
|
||||||
|
mathneon
|
||||||
|
vitashark
|
||||||
|
kubridge_stub
|
||||||
|
SceAppMgr_stub
|
||||||
|
SceAudio_stub
|
||||||
|
SceCtrl_stub
|
||||||
|
SceCommonDialog_stub
|
||||||
|
SceDisplay_stub
|
||||||
|
SceKernelDmacMgr_stub
|
||||||
|
SceGxm_stub
|
||||||
|
SceShaccCg_stub
|
||||||
|
SceSysmodule_stub
|
||||||
|
ScePower_stub
|
||||||
|
SceTouch_stub
|
||||||
|
SceVshBridge_stub
|
||||||
|
SceIofilemgr_stub
|
||||||
|
SceShaccCgExt
|
||||||
|
libtaihen_stub.a
|
||||||
|
|
||||||
|
|
||||||
|
# SceKernel_stub
|
||||||
|
SceAppUtil_stub
|
||||||
|
SceHid_stub
|
||||||
|
SceRtc_stub
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
|
||||||
|
${SDL2_INCLUDE_DIRS}
|
||||||
|
)
|
||||||
|
|
||||||
|
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
||||||
|
DUSK_SDL2
|
||||||
|
DUSK_OPENGL
|
||||||
|
DUSK_VITA
|
||||||
|
DUSK_INPUT_GAMEPAD
|
||||||
|
DUSK_PLATFORM_ENDIAN_LITTLE
|
||||||
|
DUSK_OPENGL_LEGACY
|
||||||
|
DUSK_DISPLAY_WIDTH=960
|
||||||
|
DUSK_DISPLAY_HEIGHT=544
|
||||||
|
DUSK_THREAD_PTHREAD
|
||||||
|
)
|
||||||
|
|
||||||
|
# Post-build: create SELF from the ELF binary (UNSAFE = homebrew, no signing)
|
||||||
|
vita_create_self(${DUSK_BINARY_TARGET_NAME}.self ${DUSK_BINARY_TARGET_NAME} UNSAFE)
|
||||||
|
|
||||||
|
# Post-build: package SELF + assets into a .vpk installable on the Vita
|
||||||
|
vita_create_vpk(${DUSK_BINARY_TARGET_NAME}.vpk ${VITA_TITLEID} ${DUSK_BINARY_TARGET_NAME}.self
|
||||||
|
VERSION ${VITA_VERSION}
|
||||||
|
NAME ${VITA_APP_NAME}
|
||||||
|
FILE ${DUSK_ASSETS_ZIP} dusk.dsk
|
||||||
|
)
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
FROM ghcr.io/extremscorner/libogc2
|
|
||||||
WORKDIR /workdir
|
|
||||||
RUN apt update && \
|
|
||||||
dkp-pacman -Syu --noconfirm && \
|
|
||||||
apt install -y python3 python3-pip python3-polib python3-pil python3-dotenv python3-pyqt5 python3-opengl xorriso dolphin-emu xvfb && \
|
|
||||||
dkp-pacman -S --needed --noconfirm gamecube-sdl2 ppc-liblzma ppc-libzip libogc2 gamecube-tools ppc-libmad ppc-zlib-ng ppc-liblzma ppc-bzip2 ppc-zstd
|
|
||||||
VOLUME ["/workdir"]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
FROM pspdev/pspdev:latest
|
|
||||||
WORKDIR /workdir
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
python3 \
|
|
||||||
py3-pip \
|
|
||||||
py3-dotenv \
|
|
||||||
git \
|
|
||||||
cmake \
|
|
||||||
make \
|
|
||||||
g++ \
|
|
||||||
sdl2-dev \
|
|
||||||
zlib-dev \
|
|
||||||
linux-headers
|
|
||||||
|
|
||||||
# PPSSPP has no Alpine/Linux distro package -- build the "PPSSPPHeadless"
|
|
||||||
# target (the CLI/no-window build PPSSPP's own CI uses for automated runs)
|
|
||||||
# from source instead. The target name and exact CMake options have moved
|
|
||||||
# around across PPSSPP versions -- if this breaks, check
|
|
||||||
# https://github.com/hrydgard/ppsspp's CMakeLists.txt for the current name.
|
|
||||||
RUN git clone --recursive --depth 1 \
|
|
||||||
https://github.com/hrydgard/ppsspp.git /opt/ppsspp && \
|
|
||||||
cd /opt/ppsspp && \
|
|
||||||
cmake -B build -DCMAKE_BUILD_TYPE=Release && \
|
|
||||||
cmake --build build --target PPSSPPHeadless -- -j$(nproc) && \
|
|
||||||
cp build/PPSSPPHeadless /usr/local/bin/PPSSPPHeadless && \
|
|
||||||
rm -rf /opt/ppsspp
|
|
||||||
|
|
||||||
VOLUME ["/workdir"]
|
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
# Scripting
|
||||||
|
|
||||||
|
Dusk embeds [JerryScript](https://github.com/jerryscript-project/jerryscript) to
|
||||||
|
drive gameplay logic from JavaScript. The engine itself (rendering, physics,
|
||||||
|
asset loading, entity storage) is all C; scripts sit on top and manipulate that
|
||||||
|
state through a small set of bound objects.
|
||||||
|
|
||||||
|
This document covers the JS-facing scripting API. **UI (buttons, sliders,
|
||||||
|
menus, etc.) is not exposed to scripts** — it's a separate, C-only API. See
|
||||||
|
[UI.md](UI.md) if you're building screens/menus from engine/game C code.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
On startup, `engineInit()` loads and evaluates `assets/engine.js` as the main
|
||||||
|
script, then calls the global `init()` function if one is defined. From then
|
||||||
|
on, every engine tick calls (in order):
|
||||||
|
|
||||||
|
1. `fixedUpdate()` — once per fixed timestep. Use this for gameplay logic that
|
||||||
|
must be deterministic and independent of display refresh rate (movement,
|
||||||
|
physics-adjacent input handling, etc). Skipped on interpolation/dynamic
|
||||||
|
frames when the build has variable-timestep rendering enabled.
|
||||||
|
2. `update()` — once per rendered frame, including interpolation frames. Use
|
||||||
|
this for smooth, purely presentational animation (nothing that needs to be
|
||||||
|
deterministic).
|
||||||
|
|
||||||
|
On shutdown, `deinit()` is called once.
|
||||||
|
|
||||||
|
All four hooks (`init`, `update`, `fixedUpdate`, `deinit`) are **optional** —
|
||||||
|
if a script doesn't define one, the engine simply skips it, no error.
|
||||||
|
|
||||||
|
```js
|
||||||
|
function update() {
|
||||||
|
cubePosition.rotation.y += TIME.delta * 1.5;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `async init()` and `include()`
|
||||||
|
|
||||||
|
`init` (or any of the other hooks) can be declared `async` and use `await`
|
||||||
|
freely, including awaiting `include()` (see below), even though the engine
|
||||||
|
calls these functions synchronously from C with no external JS event loop.
|
||||||
|
When a hook returns a pending `Promise`, the engine keeps driving the asset
|
||||||
|
system and JerryScript's job queue until that promise settles before
|
||||||
|
continuing — so by the time e.g. `init()` "returns" from the engine's point of
|
||||||
|
view, everything it awaited has actually finished.
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function init() {
|
||||||
|
Actions = await include("input.js");
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the awaited work throws/rejects, it surfaces as a C-level error.
|
||||||
|
|
||||||
|
## Loading other scripts: `include(path)`
|
||||||
|
|
||||||
|
```js
|
||||||
|
var Actions = await include("input.js");
|
||||||
|
```
|
||||||
|
|
||||||
|
`include(path)` always returns a `Promise`. The named file is loaded and
|
||||||
|
evaluated once no matter how many times (or from how many different scripts)
|
||||||
|
you `include()` it — later calls for the same path are handed the same
|
||||||
|
in-flight/resolved promise rather than re-running the file.
|
||||||
|
|
||||||
|
The included script communicates its result back by assigning to the bare
|
||||||
|
global `module`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// input.js
|
||||||
|
Input.bind("w", INPUT_ACTION_UP);
|
||||||
|
// ...
|
||||||
|
|
||||||
|
module = {
|
||||||
|
UP: INPUT_ACTION_UP,
|
||||||
|
DOWN: INPUT_ACTION_DOWN,
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Whatever `input.js` assigns to `module` becomes the resolved value of the
|
||||||
|
promise `include("input.js")` returned — that's what `Actions` ends up being
|
||||||
|
in the example above. If the included script throws, the promise rejects
|
||||||
|
instead.
|
||||||
|
|
||||||
|
## Full example
|
||||||
|
|
||||||
|
This is the actual shipped example content (`assets/engine.js` +
|
||||||
|
`assets/input.js`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// input.js — binds physical buttons to abstract actions, then exports the
|
||||||
|
// action constants so other scripts don't need to know raw INPUT_ACTION_* names.
|
||||||
|
Input.bind("w", INPUT_ACTION_UP);
|
||||||
|
Input.bind("s", INPUT_ACTION_DOWN);
|
||||||
|
Input.bind("a", INPUT_ACTION_LEFT);
|
||||||
|
Input.bind("d", INPUT_ACTION_RIGHT);
|
||||||
|
Input.bind("space", INPUT_ACTION_ACCEPT);
|
||||||
|
Input.bind("escape", INPUT_ACTION_RAGEQUIT);
|
||||||
|
|
||||||
|
if(typeof INPUT_GAMEPAD !== "undefined") {
|
||||||
|
Input.bind("gamepad_up", INPUT_ACTION_UP);
|
||||||
|
Input.bind("gamepad_down", INPUT_ACTION_DOWN);
|
||||||
|
Input.bind("gamepad_left", INPUT_ACTION_LEFT);
|
||||||
|
Input.bind("gamepad_right", INPUT_ACTION_RIGHT);
|
||||||
|
Input.bind("gamepad_a", INPUT_ACTION_ACCEPT);
|
||||||
|
Input.bind("gamepad_start", INPUT_ACTION_RAGEQUIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
module = {
|
||||||
|
UP: INPUT_ACTION_UP,
|
||||||
|
DOWN: INPUT_ACTION_DOWN,
|
||||||
|
LEFT: INPUT_ACTION_LEFT,
|
||||||
|
RIGHT: INPUT_ACTION_RIGHT,
|
||||||
|
ACCEPT: INPUT_ACTION_ACCEPT,
|
||||||
|
CANCEL: INPUT_ACTION_CANCEL,
|
||||||
|
RAGEQUIT: INPUT_ACTION_RAGEQUIT
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// engine.js
|
||||||
|
var Actions;
|
||||||
|
var camera, cameraPosition;
|
||||||
|
var cube, cubePosition, cubeRenderable, cubeMesh;
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
Actions = await include("input.js");
|
||||||
|
|
||||||
|
camera = new Entity();
|
||||||
|
cameraPosition = camera.add(POSITION);
|
||||||
|
camera.add(CAMERA);
|
||||||
|
cameraPosition.position = new Vec3(3, 3, -6);
|
||||||
|
cameraPosition.lookAt(new Vec3(0, 0, 0));
|
||||||
|
|
||||||
|
cube = new Entity();
|
||||||
|
cubePosition = cube.add(POSITION);
|
||||||
|
cubeRenderable = cube.add(RENDERABLE);
|
||||||
|
cubeMesh = Mesh.createCube();
|
||||||
|
cubeRenderable.mesh = cubeMesh;
|
||||||
|
cubeRenderable.color = Color.red();
|
||||||
|
}
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
cubePosition.rotation.y += TIME.delta * 1.5;
|
||||||
|
cubePosition.rotation.x += TIME.delta * 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedUpdate() {
|
||||||
|
var move = 3.0 * TIME.delta;
|
||||||
|
if(Input.isDown(Actions.LEFT)) cubePosition.position.x -= move;
|
||||||
|
if(Input.isDown(Actions.RIGHT)) cubePosition.position.x += move;
|
||||||
|
if(Input.isDown(Actions.UP)) cubePosition.position.z += move;
|
||||||
|
if(Input.isDown(Actions.DOWN)) cubePosition.position.z -= move;
|
||||||
|
if(Input.pressed(Actions.ACCEPT)) cubePosition.position = new Vec3(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deinit() {
|
||||||
|
cube.dispose();
|
||||||
|
camera.dispose();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API reference
|
||||||
|
|
||||||
|
### `TIME`
|
||||||
|
|
||||||
|
Plain global object, live getters (read fresh engine state every access, not
|
||||||
|
snapshotted):
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `TIME.delta` | number | Seconds since the last frame. |
|
||||||
|
| `TIME.time` | number | Total elapsed engine time, in seconds. |
|
||||||
|
|
||||||
|
### `PLATFORM`
|
||||||
|
|
||||||
|
A single global string constant — the compile-time target name, e.g.
|
||||||
|
`"linux"`, `"psp"`, `"vita"`, `"dolphin"`. Individual platform builds may
|
||||||
|
inject additional platform-specific globals via their own
|
||||||
|
`modulePlatformPlatform()` hook; those aren't documented here since they vary
|
||||||
|
per target.
|
||||||
|
|
||||||
|
### `Input`
|
||||||
|
|
||||||
|
Static namespace (not constructible — there's no `new Input()`).
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|---|---|
|
||||||
|
| `Input.bind(buttonName, action)` | Binds a physical button/key (string, e.g. `"w"`, `"space"`, `"gamepad_up"`) to an abstract `INPUT_ACTION_*` constant. Many buttons can bind to the same action. Throws on an empty/unrecognized button name or invalid action. |
|
||||||
|
| `Input.isDown(action)` → boolean | Is the action currently held. |
|
||||||
|
| `Input.pressed(action)` → boolean | Action transitioned to down this frame. |
|
||||||
|
| `Input.released(action)` → boolean | Action transitioned to up this frame. |
|
||||||
|
| `Input.getValue(action)` → number | Current analog value for the action. |
|
||||||
|
| `Input.axis(negAction, posAction)` → number | Combined axis value from two opposing actions. |
|
||||||
|
| `Input.axis2D(negX, posX, negY, posY)` → `Vec2` | Combined 2D axis from four actions. |
|
||||||
|
|
||||||
|
Global `INPUT_ACTION_*` constants (names are stable API; treat the numeric
|
||||||
|
values as opaque/build-specific): `INPUT_ACTION_UP`, `INPUT_ACTION_DOWN`,
|
||||||
|
`INPUT_ACTION_LEFT`, `INPUT_ACTION_RIGHT`, `INPUT_ACTION_ACCEPT`,
|
||||||
|
`INPUT_ACTION_CANCEL`, `INPUT_ACTION_PAUSE`, `INPUT_ACTION_RAGEQUIT`,
|
||||||
|
`INPUT_ACTION_CONSOLE`, `INPUT_ACTION_POINTERX`, `INPUT_ACTION_POINTERY`.
|
||||||
|
|
||||||
|
Conditionally-defined boolean globals reflecting build capability — only
|
||||||
|
present at all if the corresponding input method is compiled in, so
|
||||||
|
feature-test with `typeof`, don't assume they exist:
|
||||||
|
`INPUT_KEYBOARD`, `INPUT_GAMEPAD`, `INPUT_POINTER`, `INPUT_TOUCH`.
|
||||||
|
|
||||||
|
### `Vec2` / `Vec3` / `Vec4`
|
||||||
|
|
||||||
|
`new Vec2(x?, y?)`, `new Vec3(x?, y?, z?)`, `new Vec4(x?, y?, z?, w?)` — all
|
||||||
|
components optional, default `0`.
|
||||||
|
|
||||||
|
Common instance surface across all three: `.dot(other)`, `.length()`,
|
||||||
|
`.lengthSq()`, `.normalize()`, `.negate()`, `.add(other)`, `.sub(other)`,
|
||||||
|
`.scale(n)`, `.lerp(other, t)` — each of `add`/`sub`/`scale`/`negate`/
|
||||||
|
`normalize`/`lerp` returns a **new** vector (non-mutating). `Vec3` additionally
|
||||||
|
has `.cross(other)` and `.distance(other)`; `Vec2` has `.distance(other)` too;
|
||||||
|
`Vec4` has neither `.cross()` nor `.distance()`.
|
||||||
|
|
||||||
|
`Vec4` also has UV aliases over the same four floats: `.u0` (= `.x`), `.v0`
|
||||||
|
(= `.y`), `.u1` (= `.z`), `.v1` (= `.w`) — handy for texture-rect style code.
|
||||||
|
|
||||||
|
All three have `.x`/`.y`(/`.z`/`.w`) get/set properties and a `.toString()`
|
||||||
|
like `"Vec3(1, 2, 3)"`.
|
||||||
|
|
||||||
|
**"Vec3Ref" — live references.** Several engine properties (entity
|
||||||
|
`position`/`rotation`/`scale`, physics `velocity`, a mesh vertex's `position`)
|
||||||
|
return a vector-*like* object instead of a plain `Vec3`. It has the identical
|
||||||
|
`.x`/`.y`/`.z` surface, but reads/writes go straight into the underlying
|
||||||
|
native buffer — writing `.x` on `entity.position.position` immediately moves
|
||||||
|
the entity, no separate assignment needed. Anywhere the API expects a `Vec3`
|
||||||
|
argument, a Vec3Ref works too. You never construct one directly; you only
|
||||||
|
ever receive them from properties like the ones above.
|
||||||
|
|
||||||
|
### `Mat4`
|
||||||
|
|
||||||
|
`new Mat4()` — always constructs identity; no other constructor form.
|
||||||
|
|
||||||
|
| Member | Description |
|
||||||
|
|---|---|
|
||||||
|
| `.mul(other)` → `Mat4` | `this * other`. |
|
||||||
|
| `.transpose()` → `Mat4` | |
|
||||||
|
| `.inverse()` → `Mat4` | |
|
||||||
|
| `.determinant()` → number | |
|
||||||
|
| `.mulVec3(vec3, w?)` → `Vec3` | `w` defaults to `1.0` (point); pass `0` for a direction. |
|
||||||
|
| `.mulVec4(vec4)` → `Vec4` | |
|
||||||
|
| `.translate(vec3)` → `Mat4` | Non-mutating — returns a translated copy. |
|
||||||
|
| `.scale(vec3)` → `Mat4` | Non-mutating — returns a scaled copy. |
|
||||||
|
| `Mat4.identity()` → `Mat4` | Static. |
|
||||||
|
| `Mat4.perspective(fov, aspect, near, far)` → `Mat4` | Static, all 4 args required. |
|
||||||
|
| `Mat4.lookAt(eye, center, up)` → `Mat4` | Static, all 3 args required `Vec3`s. |
|
||||||
|
|
||||||
|
### `Color`
|
||||||
|
|
||||||
|
`new Color(r?, g?, b?, a?)` — each an int `0..255`, default `255` (so
|
||||||
|
`new Color()` is opaque white). Properties `.r`/`.g`/`.b`/`.a` get/set.
|
||||||
|
|
||||||
|
Named factories, each a zero-arg static returning a new opaque `Color`
|
||||||
|
(alpha `255` unless noted): `Color.black()`, `Color.white()`, `Color.red()`,
|
||||||
|
`Color.green()`, `Color.blue()`, `Color.yellow()`, `Color.cyan()`,
|
||||||
|
`Color.magenta()`, `Color.transparent()` (alpha 0), `Color.transparent_white()`
|
||||||
|
(alpha 0), `Color.transparent_black()` (alpha 0), `Color.gray()`,
|
||||||
|
`Color.light_gray()`, `Color.dark_gray()`, `Color.orange()`, `Color.purple()`,
|
||||||
|
`Color.brown()`, `Color.pink()`, `Color.lime()`, `Color.navy()`,
|
||||||
|
`Color.teal()`, `Color.cornflower_blue()`.
|
||||||
|
|
||||||
|
`Color.rainbow(t?, speed?)` → `Color` — `t` defaults to `TIME.time * 4.0`;
|
||||||
|
produces a shifting rainbow color, useful for debug visuals.
|
||||||
|
|
||||||
|
### `Mesh`
|
||||||
|
|
||||||
|
`new Mesh(vertexCount)` — allocates an uninitialized CPU-side vertex buffer
|
||||||
|
(not yet uploaded to the GPU).
|
||||||
|
|
||||||
|
| Member | Description |
|
||||||
|
|---|---|
|
||||||
|
| `.vertices` | Array of vertex wrappers, each with a `.position` (Vec3Ref, writes straight into that vertex). |
|
||||||
|
| `.vertexCount` | Read-only. |
|
||||||
|
| `.flush()` | Uploads to the GPU. First call initializes the GPU mesh; later calls re-upload the current vertex data — call this after editing `.vertices[i].position`. |
|
||||||
|
| `.dispose()` | Frees GPU + CPU resources. |
|
||||||
|
|
||||||
|
Static engine-owned singletons (read-only, not something you dispose):
|
||||||
|
`Mesh.DEFAULT_CUBE`, `Mesh.DEFAULT_QUAD`, `Mesh.DEFAULT_SPHERE`,
|
||||||
|
`Mesh.DEFAULT_PLANE`, `Mesh.DEFAULT_CAPSULE`, `Mesh.DEFAULT_TRIPRISM`.
|
||||||
|
|
||||||
|
Static factories (each builds and uploads a brand-new `Mesh`):
|
||||||
|
|
||||||
|
| Factory | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `Mesh.createCube(min?, max?)` | Both `Vec3`, default `(-0.5,-0.5,-0.5)`..`(0.5,0.5,0.5)`. |
|
||||||
|
| `Mesh.createQuad(minX?, minY?, maxX?, maxY?)` | Default `-0.5..0.5` both axes; UV fixed `0,0`–`1,1`. |
|
||||||
|
| `Mesh.createSphere(radius?, stacks?, sectors?)` | `radius` default `0.5`. |
|
||||||
|
| `Mesh.createPlane(width?, height?)` | Defaults `1.0`/`1.0`; XZ-aligned, centered at origin. |
|
||||||
|
| `Mesh.createCapsule(radius?, halfHeight?, capRings?, sectors?)` | Defaults `0.5`, `0.5`. |
|
||||||
|
| `Mesh.createTriPrism(x0, y0, x1, y1, x2, y2, minZ, maxZ)` | All 8 args required — a triangular cross-section extruded along Z. |
|
||||||
|
|
||||||
|
### `Entity` and components
|
||||||
|
|
||||||
|
```js
|
||||||
|
var e = new Entity();
|
||||||
|
var pos = e.add(POSITION);
|
||||||
|
```
|
||||||
|
|
||||||
|
`new Entity()` allocates an entity. `.id` is the read-only numeric engine ID.
|
||||||
|
`.add(TYPE)` adds a component and returns its wrapper (`TYPE` is one of the
|
||||||
|
constants below). `.dispose()` removes the entity and all its components.
|
||||||
|
|
||||||
|
Component-type constants: `POSITION`, `CAMERA`, `RENDERABLE`, `PHYSICS`,
|
||||||
|
`TRIGGER`. Each entity also exposes a lowercase getter that returns the
|
||||||
|
existing wrapper if the component is present, or `undefined` if not (it does
|
||||||
|
**not** add the component — use `.add()` for that): `entity.position`,
|
||||||
|
`entity.camera`, `entity.renderable`, `entity.physics`, `entity.trigger`.
|
||||||
|
|
||||||
|
#### `entity.add(POSITION)` → position component
|
||||||
|
|
||||||
|
| Member | Description |
|
||||||
|
|---|---|
|
||||||
|
| `.position` | Vec3Ref. Writing rebuilds the transform automatically. |
|
||||||
|
| `.rotation` | Vec3Ref, Euler angles. Same rebuild-on-write behavior. |
|
||||||
|
| `.scale` | Vec3Ref. Same rebuild-on-write behavior. |
|
||||||
|
| `.parent` | Get/set another position-component wrapper, or `null` to clear parenting. |
|
||||||
|
| `.lookAt(target, up?)` | `target` a `Vec3`; `up` defaults to `(0,1,0)`. |
|
||||||
|
|
||||||
|
#### `entity.add(CAMERA)` → camera component
|
||||||
|
|
||||||
|
| Member | Description |
|
||||||
|
|---|---|
|
||||||
|
| `.zNear` / `.zFar` | Numbers. |
|
||||||
|
| `.fov` | Only meaningful when `projectionType` is `CAMERA_TYPE_PERSPECTIVE`; otherwise get returns `undefined` and set is a no-op. |
|
||||||
|
| `.projectionType` | `CAMERA_TYPE_PERSPECTIVE` or `CAMERA_TYPE_ORTHOGRAPHIC`. |
|
||||||
|
| `.orthoTop` / `.orthoBottom` / `.orthoLeft` / `.orthoRight` | Only meaningful in orthographic mode, same undefined/no-op rule otherwise. |
|
||||||
|
|
||||||
|
#### `entity.add(RENDERABLE)` → renderable component
|
||||||
|
|
||||||
|
| Member | Description |
|
||||||
|
|---|---|
|
||||||
|
| `.type` | `ENTITY_RENDERABLE_TYPE_MATERIAL`, `_SPRITEBATCH`, or `_CALLBACK`. |
|
||||||
|
| `.mesh` | Get/set a `Mesh` instance or a `Mesh.DEFAULT_*` singleton. |
|
||||||
|
| `.color` | Get/set a `Color` instance (throws if given something else). |
|
||||||
|
| `.addSprite({ min?, max?, uvMin?, uvMax? })` | Adds a sprite to this renderable's sprite batch; all fields optional, default zero. |
|
||||||
|
| `.clearSprites()` | Clears the sprite batch. |
|
||||||
|
| `.setCallback(fn?)` | Switches to `ENTITY_RENDERABLE_TYPE_CALLBACK` and calls `fn()` on every render of this entity. Omit/pass non-function to clear. Exceptions inside `fn` surface as a C error. |
|
||||||
|
|
||||||
|
#### `entity.add(PHYSICS)` → physics component
|
||||||
|
|
||||||
|
| Member | Description |
|
||||||
|
|---|---|
|
||||||
|
| `.velocity` | Vec3Ref, plain (no rebuild-on-write). |
|
||||||
|
| `.onGround` | Read-only boolean. |
|
||||||
|
| `.bodyType` | `PHYSICS_BODY_STATIC`, `PHYSICS_BODY_DYNAMIC`, `PHYSICS_BODY_KINEMATIC`. |
|
||||||
|
| `.applyImpulse(vec3)` | Adds to velocity. No-op on static bodies. |
|
||||||
|
| `.setShapeCube(halfExtents)` | `halfExtents` a `Vec3`. |
|
||||||
|
| `.setShapeSphere(radius)` | Number. |
|
||||||
|
| `.setShapeCapsule(radius, halfHeight)` | Two numbers. |
|
||||||
|
| `.setShapePlane(normal, distance)` | `Vec3` + number. |
|
||||||
|
|
||||||
|
Shape-type constants (for reading `.type` on the underlying shape, not for
|
||||||
|
`.bodyType`): `PHYSICS_SHAPE_CUBE`, `PHYSICS_SHAPE_SPHERE`,
|
||||||
|
`PHYSICS_SHAPE_CAPSULE`, `PHYSICS_SHAPE_PLANE`.
|
||||||
|
|
||||||
|
#### `entity.add(TRIGGER)` → trigger component
|
||||||
|
|
||||||
|
| Member | Description |
|
||||||
|
|---|---|
|
||||||
|
| `.min` / `.max` | Plain `Vec3` values (copies, not live refs). |
|
||||||
|
| `.setBounds(min, max)` | Sets both at once. |
|
||||||
|
| `.contains(point)` → boolean | `point` a `Vec3`. |
|
||||||
|
|
||||||
|
## Not yet available to scripts
|
||||||
|
|
||||||
|
The following C modules exist and are fully implemented, but aren't currently
|
||||||
|
wired into script registration (`moduleRegister()` in
|
||||||
|
`src/dusk/script/module/module.h`), so none of these globals exist in a
|
||||||
|
script today: `Screen`, `SpriteBatch`, `Text`, `Scene`, `Easing`, `Console`,
|
||||||
|
`Engine`. If you need one of these from a script, it needs to be registered
|
||||||
|
in `moduleRegister()` first — see the existing entries there and the modules
|
||||||
|
under `src/dusk/script/module/` for the pattern to follow.
|
||||||
+255
@@ -0,0 +1,255 @@
|
|||||||
|
# UI
|
||||||
|
|
||||||
|
Dusk's UI system (buttons, checkboxes, sliders, dropdowns, tabs, menus, focus
|
||||||
|
navigation) is a **C-only API**. It is not exposed to JerryScript — see
|
||||||
|
[SCRIPTING.md](SCRIPTING.md) for what scripts *can* touch. If you need a
|
||||||
|
script to open/react to a menu, wire it through a C callback or a game-side
|
||||||
|
flag scripts can poll; there's no bridge for this today.
|
||||||
|
|
||||||
|
## Mental model
|
||||||
|
|
||||||
|
There is no retained-mode UI tree, no automatic dispatch, no scissor/clip-rect
|
||||||
|
API. Every widget is a plain struct you own (usually as a global or
|
||||||
|
scene-owned variable). You call its `xxxInit(...)` once, then call its
|
||||||
|
`xxxDraw(widget, x, y)` yourself, every frame you want it visible, at whatever
|
||||||
|
screen position you choose. Nothing draws itself automatically except three
|
||||||
|
fixed system overlays (overscan bars, debug console, FPS counter) — see
|
||||||
|
[System overlays](#system-overlays-automatic) below.
|
||||||
|
|
||||||
|
### Where UI rendering happens in the frame
|
||||||
|
|
||||||
|
- `uiInit()` / `uiDispose()` run once, at engine startup/shutdown.
|
||||||
|
- `uiUpdate()` runs once per tick (drives focus-navigation input handling).
|
||||||
|
- `uiRender()` runs once per frame, called from inside `sceneRender()` — i.e.
|
||||||
|
**after** the active scene's own 3D/game-world rendering, using an
|
||||||
|
orthographic screen-space projection. Your own widget `xxxDraw()` calls
|
||||||
|
should happen around the same point — typically from your scene's render
|
||||||
|
callback, after world content, so UI draws on top.
|
||||||
|
|
||||||
|
## Widgets
|
||||||
|
|
||||||
|
Every widget follows the same shape: `xxxInit(widget, ...)` zeroes the struct
|
||||||
|
and sets its fields; `xxxDraw(const widget*, x, y) -> errorret_t` draws it at
|
||||||
|
that screen position.
|
||||||
|
|
||||||
|
> **Init before Draw.** `uislider_t`, `uidropdown_t`, and `uitab_t` cache
|
||||||
|
> their label's measured width/height at `Init` time (an optimization —
|
||||||
|
> label text doesn't change after that point). Calling `Draw` before `Init`,
|
||||||
|
> or mutating `->label` directly instead of re-initializing, leaves stale
|
||||||
|
> layout. `uibutton_t`/`uicheckbox_t` don't have this restriction.
|
||||||
|
|
||||||
|
### Button
|
||||||
|
|
||||||
|
```c
|
||||||
|
void uiButtonInit(uibutton_t *button, const char_t *label);
|
||||||
|
bool_t uiButtonIsHighlighted(const uibutton_t *button);
|
||||||
|
void uiButtonSetHighlighted(uibutton_t *button, bool_t highlighted);
|
||||||
|
errorret_t uiButtonDraw(const uibutton_t *button, float_t x, float_t y);
|
||||||
|
```
|
||||||
|
|
||||||
|
Draws `label` in red when highlighted, white otherwise.
|
||||||
|
|
||||||
|
### Checkbox
|
||||||
|
|
||||||
|
```c
|
||||||
|
void uiCheckboxInit(uicheckbox_t *checkbox, const char_t *label);
|
||||||
|
bool_t uiCheckboxIsChecked(const uicheckbox_t *checkbox);
|
||||||
|
void uiCheckboxSetChecked(uicheckbox_t *checkbox, bool_t checked);
|
||||||
|
void uiCheckboxToggle(uicheckbox_t *checkbox);
|
||||||
|
bool_t uiCheckboxIsHighlighted(const uicheckbox_t *checkbox);
|
||||||
|
void uiCheckboxSetHighlighted(uicheckbox_t *checkbox, bool_t highlighted);
|
||||||
|
errorret_t uiCheckboxDraw(const uicheckbox_t *checkbox, float_t x, float_t y);
|
||||||
|
```
|
||||||
|
|
||||||
|
Draws `"Y "`/`"N "` then the label.
|
||||||
|
|
||||||
|
### Slider
|
||||||
|
|
||||||
|
```c
|
||||||
|
typedef union { float_t f; int32_t i; } uislidervalue_t;
|
||||||
|
|
||||||
|
void uiSliderInitFloat(uislider_t*, const char_t *label,
|
||||||
|
float_t value, float_t min, float_t max, float_t step);
|
||||||
|
void uiSliderInitInt(uislider_t*, const char_t *label,
|
||||||
|
int32_t value, int32_t min, int32_t max, int32_t step);
|
||||||
|
float_t uiSliderGetFloat(const uislider_t*); // works for either type
|
||||||
|
int32_t uiSliderGetInt(const uislider_t*); // asserts type == INT
|
||||||
|
void uiSliderSetFloat(uislider_t*, float_t value); // asserts type == FLOAT, clamps
|
||||||
|
void uiSliderSetInt(uislider_t*, int32_t value); // asserts type == INT, clamps
|
||||||
|
void uiSliderStepUp(uislider_t*); // wraps to min past max
|
||||||
|
void uiSliderStepDown(uislider_t*); // wraps to max past min
|
||||||
|
float_t uiSliderGetRatio(const uislider_t*); // normalized 0..1
|
||||||
|
int32_t uiSliderGetStepCount(const uislider_t*); // 0 for float sliders
|
||||||
|
bool_t uiSliderIsHighlighted(const uislider_t*);
|
||||||
|
void uiSliderSetHighlighted(uislider_t*, bool_t highlighted);
|
||||||
|
errorret_t uiSliderDraw(const uislider_t*, float_t x, float_t y);
|
||||||
|
```
|
||||||
|
|
||||||
|
Draws label, a track, a fill proportional to the current ratio, discrete step
|
||||||
|
markers if it's an int slider with fewer than 10 steps, then the value as
|
||||||
|
text.
|
||||||
|
|
||||||
|
### Dropdown
|
||||||
|
|
||||||
|
```c
|
||||||
|
void uiDropdownInit(uidropdown_t *dropdown, const char_t *label,
|
||||||
|
const char_t *const *options, uint8_t optionCount,
|
||||||
|
uint8_t selectedIndex);
|
||||||
|
uint8_t uiDropdownGetSelectedIndex(const uidropdown_t *dropdown);
|
||||||
|
const char_t *uiDropdownGetSelectedOption(const uidropdown_t *dropdown);
|
||||||
|
void uiDropdownSetSelectedIndex(uidropdown_t *dropdown, uint8_t index);
|
||||||
|
void uiDropdownStepNext(uidropdown_t *dropdown); // wraps
|
||||||
|
void uiDropdownStepPrev(uidropdown_t *dropdown); // wraps
|
||||||
|
bool_t uiDropdownIsHighlighted(const uidropdown_t *dropdown);
|
||||||
|
void uiDropdownSetHighlighted(uidropdown_t *dropdown, bool_t highlighted);
|
||||||
|
errorret_t uiDropdownDraw(const uidropdown_t *dropdown, float_t x, float_t y);
|
||||||
|
```
|
||||||
|
|
||||||
|
`options` is a caller-owned array of strings that must outlive the dropdown
|
||||||
|
(it isn't copied). Draws `label` then `"< Option >"`.
|
||||||
|
|
||||||
|
### Tab
|
||||||
|
|
||||||
|
```c
|
||||||
|
void uiTabInit(uitab_t *tab, const char_t *label);
|
||||||
|
bool_t uiTabIsActive(const uitab_t *tab);
|
||||||
|
void uiTabSetActive(uitab_t *tab, bool_t active);
|
||||||
|
errorret_t uiTabDraw(const uitab_t *tab, float_t x, float_t y);
|
||||||
|
```
|
||||||
|
|
||||||
|
Draws a background box sized to the label (green if active, red if inactive)
|
||||||
|
with the label on top.
|
||||||
|
|
||||||
|
## Menus: assembling widgets into a navigable list
|
||||||
|
|
||||||
|
`uimenu_t` is the one aggregate widget — it owns an array of items (labels,
|
||||||
|
spacers, and any of the widgets above), lays them out in a grid, and wires
|
||||||
|
keyboard/gamepad navigation via the focus system for you.
|
||||||
|
|
||||||
|
```c
|
||||||
|
typedef enum {
|
||||||
|
UI_MENU_WIDGET_TYPE_NONE, UI_MENU_WIDGET_TYPE_LABEL,
|
||||||
|
UI_MENU_WIDGET_TYPE_SPACER, UI_MENU_WIDGET_TYPE_CHECKBOX,
|
||||||
|
UI_MENU_WIDGET_TYPE_BUTTON, UI_MENU_WIDGET_TYPE_TAB,
|
||||||
|
UI_MENU_WIDGET_TYPE_SLIDER, UI_MENU_WIDGET_TYPE_DROPDOWN,
|
||||||
|
} uimenuwidgettype_t;
|
||||||
|
|
||||||
|
void uiMenuInit(uimenu_t *menu, uimenuselectedcallback_t selected,
|
||||||
|
uimenuclosedcallback_t closed, uimenuchangedcallback_t changed);
|
||||||
|
void uiMenuSetItems(uimenu_t *menu, const uimenuitem_t *items,
|
||||||
|
uint8_t itemCount, uint8_t columns);
|
||||||
|
void uiMenuSetPosition(uimenu_t *menu, uint8_t x, uint8_t y); // focus cursor cell, not pixels
|
||||||
|
void uiMenuOpen(uimenu_t *menu); // pushes onto the focus stack
|
||||||
|
void uiMenuClose(uimenu_t *menu); // pops it
|
||||||
|
bool_t uiMenuIsActive(const uimenu_t *menu);
|
||||||
|
errorret_t uiMenuDraw(const uimenu_t *menu, float_t x, float_t y,
|
||||||
|
float_t width, float_t height);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `selected(menu, index, item)` fires when the player presses accept on an item.
|
||||||
|
- `changed(menu, index, item)` fires when the highlighted item changes.
|
||||||
|
- `closed(menu)` fires when the menu is popped off the focus stack.
|
||||||
|
- LEFT/RIGHT on a highlighted slider/checkbox/dropdown adjusts its value in
|
||||||
|
place instead of moving focus off it (handled internally).
|
||||||
|
|
||||||
|
### Building a menu with the `MENU_*` macros
|
||||||
|
|
||||||
|
`uimenu.h` provides macros that cut the boilerplate of filling in a
|
||||||
|
`uimenuitem_t` array. They expand into statements using local variables named
|
||||||
|
`menu`, `menuIndex`, and `menuCapacity`, so use them together, inside one
|
||||||
|
function, starting with `MENU_BEGIN` and ending with `MENU_END`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
static uimenuitem_t optionsItems[8];
|
||||||
|
static uimenu_t optionsMenu;
|
||||||
|
static const char_t *qualityOptions[] = { "Low", "Medium", "High" };
|
||||||
|
|
||||||
|
static void onOptionsSelected(
|
||||||
|
const uimenu_t *menu, const uint8_t index, const uimenuitem_t *item
|
||||||
|
) {
|
||||||
|
if(index == 4) uiMenuClose(&optionsMenu); // "Back" button
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onOptionsClosed(const uimenu_t *menu) {
|
||||||
|
// e.g. return to the previous screen
|
||||||
|
}
|
||||||
|
|
||||||
|
void optionsMenuBuild(void) {
|
||||||
|
MENU_BEGIN(&optionsMenu, optionsItems, onOptionsSelected, onOptionsClosed, NULL);
|
||||||
|
MENU_LABEL("Options");
|
||||||
|
MENU_CHECKBOX("Fullscreen");
|
||||||
|
MENU_SLIDER_FLOAT("Volume", 0.8f, 0.0f, 1.0f, 0.05f);
|
||||||
|
MENU_DROPDOWN("Quality", qualityOptions, 3, 1);
|
||||||
|
MENU_BUTTON("Back");
|
||||||
|
MENU_END(optionsItems, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once, when the menu screen becomes active:
|
||||||
|
uiMenuOpen(&optionsMenu);
|
||||||
|
|
||||||
|
// Every frame the menu should be visible:
|
||||||
|
uiMenuDraw(&optionsMenu, 20.0f, 20.0f, 200.0f, 100.0f);
|
||||||
|
|
||||||
|
// When leaving the menu screen:
|
||||||
|
uiMenuClose(&optionsMenu);
|
||||||
|
```
|
||||||
|
|
||||||
|
`MENU_LABEL`/`MENU_SPACER` force a row break and aren't focusable/selectable.
|
||||||
|
Every other `MENU_*` macro calls the matching widget's own `Init` for you.
|
||||||
|
|
||||||
|
> This example is constructed directly from the widget/menu API surface (all
|
||||||
|
> function and macro signatures above are verified against the source), but
|
||||||
|
> there's currently no real menu-building call site anywhere else in the
|
||||||
|
> engine to cross-check the *pattern* against — treat it as a starting point,
|
||||||
|
> not a copy of shipped code.
|
||||||
|
|
||||||
|
## Focus system: navigation underneath `uimenu`
|
||||||
|
|
||||||
|
If you're building a custom widget that needs keyboard/gamepad navigation
|
||||||
|
without going through `uimenu`, use `ui/focus/uifocus.h` directly. `uimenu`
|
||||||
|
is implemented entirely in terms of this API, so it's a reasonable reference.
|
||||||
|
|
||||||
|
```c
|
||||||
|
uifocusitem_t * uiFocusPush(
|
||||||
|
uint8_t cols, uint8_t rows,
|
||||||
|
uifocusitemcallback_t selected, // fires on accept
|
||||||
|
uifocusitemcallback_t changed, // fires on cursor move (and once immediately)
|
||||||
|
uifocusitemcallback_t closed, // fires on pop
|
||||||
|
uifocusitemdirectioncallback_t direction, // optional pre-empt of a direction press; NULL for default grid movement
|
||||||
|
void *user
|
||||||
|
);
|
||||||
|
void uiFocusPop(void);
|
||||||
|
void uiFocusPopItem(uifocusitem_t *item);
|
||||||
|
void uiFocusSetPosition(uifocusitem_t *item, uint8_t x, uint8_t y); // wraps
|
||||||
|
void uiFocusMoveDirection(uifocusitem_t *item, uifocusdirection_t dir);
|
||||||
|
```
|
||||||
|
|
||||||
|
`uiFocusUpdate()` runs automatically from `uiUpdate()` every tick — you don't
|
||||||
|
call it yourself. It reads `INPUT_ACTION_ACCEPT` (fires `selected`),
|
||||||
|
`INPUT_ACTION_CANCEL` (pops the stack), and the four directional actions
|
||||||
|
(with hold-to-repeat timing) to move the cursor within the topmost pushed
|
||||||
|
item. Only the topmost stack entry (max depth 8) receives input at a time —
|
||||||
|
opening a submenu means pushing a new focus item on top; closing it pops back
|
||||||
|
to the parent.
|
||||||
|
|
||||||
|
There's no separate "is this widget focused" query — "focused" is expressed
|
||||||
|
as the pushed item's current `(x, y)` cursor cell matching a given slot, which
|
||||||
|
is exactly how `uimenu`'s `changed` callback decides which item to highlight.
|
||||||
|
|
||||||
|
## System overlays (automatic)
|
||||||
|
|
||||||
|
Three small overlays are wired into a fixed internal list and draw themselves
|
||||||
|
every frame with no call needed from game code:
|
||||||
|
|
||||||
|
- **Overscan bars** (`ui/overlay/uicrop.h`) — draws opaque bars over the
|
||||||
|
screen area outside `SCREEN.scanX/scanY/scanWidth/scanHeight` (the
|
||||||
|
overscan-safe viewport). A no-op on platforms/configs where the scan area
|
||||||
|
already equals the full viewport. `UI_CROP.color` (default black) is the
|
||||||
|
only thing you'd normally touch here.
|
||||||
|
- **Debug console** (`ui/debug/uiconsole.h`) — draws console history when
|
||||||
|
visible.
|
||||||
|
- **FPS counter** (`ui/debug/uifps.h`) — draws a live FPS/frame-time readout.
|
||||||
|
|
||||||
|
None of these have a scissor/clip-rect equivalent for your own widgets —
|
||||||
|
there is no clipping API in this UI system; everything draws unclipped at
|
||||||
|
whatever position you give it.
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
// Copyright (c) 2026 Dominic Masters
|
|
||||||
//
|
|
||||||
// This software is released under the MIT License.
|
|
||||||
// https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// ChunkTerrain - JS port of the terrain-quad generation performed by
|
|
||||||
// tools/asset/chunk/__main__.py (build_terrain_verts / _tile_quad /
|
|
||||||
// _RAMP_CORNERS) so the editor's 3D preview can show the same geometry the
|
|
||||||
// Python compiler will bake into the chunk's terrain mesh, without waiting
|
|
||||||
// for a compile pass. Vertex layout matches DMF: interleaved [u, v, x, y, z].
|
|
||||||
|
|
||||||
const ChunkTerrain = (() => {
|
|
||||||
const CHUNK_WIDTH = 16;
|
|
||||||
const CHUNK_HEIGHT = 16;
|
|
||||||
const CHUNK_DEPTH = 4;
|
|
||||||
const TILE_COUNT = CHUNK_WIDTH * CHUNK_HEIGHT * CHUNK_DEPTH;
|
|
||||||
|
|
||||||
// World-space Z distance of one Z-layer/story, in world-float space.
|
|
||||||
// Must match WORLD_LAYER_HEIGHT in src/dusk/rpg/overworld/worldpos.h.
|
|
||||||
const WORLD_LAYER_HEIGHT = Math.SQRT1_2;
|
|
||||||
|
|
||||||
const TILE_SHAPE_NULL = 0;
|
|
||||||
const TILE_SHAPE_GROUND = 1;
|
|
||||||
const TILE_SHAPE_RAMP_NORTH = 2;
|
|
||||||
const TILE_SHAPE_RAMP_EAST = 3;
|
|
||||||
const TILE_SHAPE_RAMP_SOUTH = 4;
|
|
||||||
const TILE_SHAPE_RAMP_WEST = 5;
|
|
||||||
const TILE_SHAPE_RAMP_NORTHEAST = 6;
|
|
||||||
const TILE_SHAPE_RAMP_NORTHWEST = 7;
|
|
||||||
const TILE_SHAPE_RAMP_SOUTHEAST = 8;
|
|
||||||
const TILE_SHAPE_RAMP_SOUTHWEST = 9;
|
|
||||||
const TILE_SHAPE_RAMP_NORTHEAST_INNER = 10;
|
|
||||||
const TILE_SHAPE_RAMP_NORTHWEST_INNER = 11;
|
|
||||||
const TILE_SHAPE_RAMP_SOUTHEAST_INNER = 12;
|
|
||||||
const TILE_SHAPE_RAMP_SOUTHWEST_INNER = 13;
|
|
||||||
|
|
||||||
// Per-shape corner heights as [sw, se, ne, nw] offsets from tile base Z.
|
|
||||||
// NORTH=+Y, EAST=+X. Cardinal ramps: low face at 0, high face at 1.
|
|
||||||
// Diagonal outer-corner ramps: only the named corner raised, rest at 0.
|
|
||||||
// Diagonal inner-corner ramps: only the corner opposite the named one is
|
|
||||||
// lowered to 0, the rest (including the named corner) stay raised at 1.
|
|
||||||
const RAMP_CORNERS = {
|
|
||||||
[TILE_SHAPE_GROUND]: [0.0, 0.0, 0.0, 0.0],
|
|
||||||
[TILE_SHAPE_RAMP_NORTH]: [0.0, 0.0, 1.0, 1.0],
|
|
||||||
[TILE_SHAPE_RAMP_SOUTH]: [1.0, 1.0, 0.0, 0.0],
|
|
||||||
[TILE_SHAPE_RAMP_EAST]: [0.0, 1.0, 1.0, 0.0],
|
|
||||||
[TILE_SHAPE_RAMP_WEST]: [1.0, 0.0, 0.0, 1.0],
|
|
||||||
[TILE_SHAPE_RAMP_NORTHEAST]: [0.0, 0.0, 1.0, 0.0],
|
|
||||||
[TILE_SHAPE_RAMP_NORTHWEST]: [0.0, 0.0, 0.0, 1.0],
|
|
||||||
[TILE_SHAPE_RAMP_SOUTHEAST]: [0.0, 1.0, 0.0, 0.0],
|
|
||||||
[TILE_SHAPE_RAMP_SOUTHWEST]: [1.0, 0.0, 0.0, 0.0],
|
|
||||||
[TILE_SHAPE_RAMP_NORTHEAST_INNER]: [0.0, 1.0, 1.0, 1.0],
|
|
||||||
[TILE_SHAPE_RAMP_NORTHWEST_INNER]: [1.0, 0.0, 1.0, 1.0],
|
|
||||||
[TILE_SHAPE_RAMP_SOUTHEAST_INNER]: [1.0, 1.0, 1.0, 0.0],
|
|
||||||
[TILE_SHAPE_RAMP_SOUTHWEST_INNER]: [1.0, 1.0, 0.0, 1.0],
|
|
||||||
};
|
|
||||||
|
|
||||||
function tileIndex(x, y, z) {
|
|
||||||
return x + y * CHUNK_WIDTH + z * CHUNK_WIDTH * CHUNK_HEIGHT;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Six vertices (2 CCW triangles) for a quad with per-corner Z heights.
|
|
||||||
// Corner order: SW=(fx,fy), SE=(fx+1,fy), NE=(fx+1,fy+1), NW=(fx,fy+1).
|
|
||||||
function pushTileQuad(out, u0, u1, v0, v1, fx, fy, swZ, seZ, neZ, nwZ) {
|
|
||||||
out.push(
|
|
||||||
u0, v0, fx, fy, swZ,
|
|
||||||
u1, v0, fx + 1, fy, seZ,
|
|
||||||
u1, v1, fx + 1, fy + 1, neZ,
|
|
||||||
u0, v0, fx, fy, swZ,
|
|
||||||
u1, v1, fx + 1, fy + 1, neZ,
|
|
||||||
u0, v1, fx, fy + 1, nwZ,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate terrain quads for every non-null tile in a flat tiles array
|
|
||||||
// (length TILE_COUNT, indexed via tileIndex). Returns a Float32Array of
|
|
||||||
// interleaved [u, v, x, y, z] vertices, matching DMF.VERTEX_FLOATS.
|
|
||||||
function buildTerrainVerts(tiles) {
|
|
||||||
const out = [];
|
|
||||||
for(let z = 0; z < CHUNK_DEPTH; z++) {
|
|
||||||
for(let y = 0; y < CHUNK_HEIGHT; y++) {
|
|
||||||
for(let x = 0; x < CHUNK_WIDTH; x++) {
|
|
||||||
const type = tiles[tileIndex(x, y, z)] || TILE_SHAPE_NULL;
|
|
||||||
const corners = RAMP_CORNERS[type];
|
|
||||||
if(!corners) continue;
|
|
||||||
|
|
||||||
const u0 = x / CHUNK_WIDTH;
|
|
||||||
const u1 = (x + 1) / CHUNK_WIDTH;
|
|
||||||
const v0 = y / CHUNK_HEIGHT;
|
|
||||||
const v1 = (y + 1) / CHUNK_HEIGHT;
|
|
||||||
const [sw, se, ne, nw] = corners;
|
|
||||||
|
|
||||||
pushTileQuad(
|
|
||||||
out, u0, u1, v0, v1, x, y,
|
|
||||||
(z + sw) * WORLD_LAYER_HEIGHT,
|
|
||||||
(z + se) * WORLD_LAYER_HEIGHT,
|
|
||||||
(z + ne) * WORLD_LAYER_HEIGHT,
|
|
||||||
(z + nw) * WORLD_LAYER_HEIGHT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Float32Array(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Four edges (SW-SE, SE-NE, NE-NW, NW-SW) of a tile's quad as line
|
|
||||||
// segments, lifted slightly above the terrain surface to avoid
|
|
||||||
// z-fighting - same corner-height convention as pushTileQuad().
|
|
||||||
function pushTileGridLines(out, fx, fy, swZ, seZ, neZ, nwZ) {
|
|
||||||
const eps = 0.01;
|
|
||||||
const sw = [fx, fy, swZ + eps];
|
|
||||||
const se = [fx + 1, fy, seZ + eps];
|
|
||||||
const ne = [fx + 1, fy + 1, neZ + eps];
|
|
||||||
const nw = [fx, fy + 1, nwZ + eps];
|
|
||||||
const edges = [sw, se, se, ne, ne, nw, nw, sw];
|
|
||||||
for(const [x, y, z] of edges) out.push(0, 0, x, y, z);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate grid-outline line segments for every non-null tile, following
|
|
||||||
// the same per-corner heights as buildTerrainVerts() so lines hug ramps
|
|
||||||
// instead of cutting through them. Returns a Float32Array of interleaved
|
|
||||||
// [u, v, x, y, z] vertices meant to be drawn with gl.LINES (pairs).
|
|
||||||
function buildGridLines(tiles) {
|
|
||||||
const out = [];
|
|
||||||
for(let z = 0; z < CHUNK_DEPTH; z++) {
|
|
||||||
for(let y = 0; y < CHUNK_HEIGHT; y++) {
|
|
||||||
for(let x = 0; x < CHUNK_WIDTH; x++) {
|
|
||||||
const type = tiles[tileIndex(x, y, z)] || TILE_SHAPE_NULL;
|
|
||||||
const corners = RAMP_CORNERS[type];
|
|
||||||
if(!corners) continue;
|
|
||||||
|
|
||||||
const [sw, se, ne, nw] = corners;
|
|
||||||
pushTileGridLines(
|
|
||||||
out, x, y,
|
|
||||||
(z + sw) * WORLD_LAYER_HEIGHT,
|
|
||||||
(z + se) * WORLD_LAYER_HEIGHT,
|
|
||||||
(z + ne) * WORLD_LAYER_HEIGHT,
|
|
||||||
(z + nw) * WORLD_LAYER_HEIGHT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Float32Array(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.freeze({
|
|
||||||
CHUNK_WIDTH, CHUNK_HEIGHT, CHUNK_DEPTH, TILE_COUNT, WORLD_LAYER_HEIGHT,
|
|
||||||
TILE_SHAPE_NULL, TILE_SHAPE_GROUND,
|
|
||||||
TILE_SHAPE_RAMP_NORTH, TILE_SHAPE_RAMP_EAST,
|
|
||||||
TILE_SHAPE_RAMP_SOUTH, TILE_SHAPE_RAMP_WEST,
|
|
||||||
TILE_SHAPE_RAMP_NORTHEAST, TILE_SHAPE_RAMP_NORTHWEST,
|
|
||||||
TILE_SHAPE_RAMP_SOUTHEAST, TILE_SHAPE_RAMP_SOUTHWEST,
|
|
||||||
TILE_SHAPE_RAMP_NORTHEAST_INNER, TILE_SHAPE_RAMP_NORTHWEST_INNER,
|
|
||||||
TILE_SHAPE_RAMP_SOUTHEAST_INNER, TILE_SHAPE_RAMP_SOUTHWEST_INNER,
|
|
||||||
RAMP_CORNERS, tileIndex, buildTerrainVerts, buildGridLines,
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
// Copyright (c) 2026 Dominic Masters
|
|
||||||
//
|
|
||||||
// This software is released under the MIT License.
|
|
||||||
// https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// DMF - Dusk Mesh Format
|
|
||||||
//
|
|
||||||
// Header (12 bytes):
|
|
||||||
// [0-3] "DMF\0" magic
|
|
||||||
// [4-7] uint32 version (little-endian)
|
|
||||||
// [8-11] uint32 vertCount (little-endian)
|
|
||||||
//
|
|
||||||
// Followed by vertCount vertices, each 20 bytes:
|
|
||||||
// [0-7] float32[2] uv (little-endian)
|
|
||||||
// [8-19] float32[3] pos (little-endian)
|
|
||||||
|
|
||||||
const DMF = (() => {
|
|
||||||
const MAGIC = [0x44, 0x4d, 0x46, 0x00]; // "DMF\0"
|
|
||||||
const VERSION = 1;
|
|
||||||
const HEADER_SIZE = 12;
|
|
||||||
const VERTEX_FLOATS = 5; // uv[2] + pos[3]
|
|
||||||
const VERTEX_SIZE = VERTEX_FLOATS * 4;
|
|
||||||
|
|
||||||
// Decode a DMF ArrayBuffer into { version, vertexCount, floats }, where
|
|
||||||
// floats is a Float32Array of tightly interleaved [u, v, x, y, z] per
|
|
||||||
// vertex - ready to hand straight to gl.bufferData().
|
|
||||||
function decode(buffer) {
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
const view = new DataView(buffer);
|
|
||||||
|
|
||||||
if(bytes.length < HEADER_SIZE) throw new Error("File too small to be a valid DMF");
|
|
||||||
if(
|
|
||||||
bytes[0] !== MAGIC[0] || bytes[1] !== MAGIC[1] ||
|
|
||||||
bytes[2] !== MAGIC[2] || bytes[3] !== MAGIC[3]
|
|
||||||
) {
|
|
||||||
throw new Error("Invalid DMF magic bytes - not a DMF file");
|
|
||||||
}
|
|
||||||
|
|
||||||
const version = view.getUint32(4, true);
|
|
||||||
if(version !== VERSION) {
|
|
||||||
throw new Error(`Unsupported DMF version: ${version}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const vertexCount = view.getUint32(8, true);
|
|
||||||
const expected = HEADER_SIZE + vertexCount * VERTEX_SIZE;
|
|
||||||
if(bytes.length < expected) {
|
|
||||||
throw new Error(`DMF vertex data truncated (expected ${expected} bytes, got ${bytes.length})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const floats = new Float32Array(vertexCount * VERTEX_FLOATS);
|
|
||||||
for(let i = 0; i < floats.length; i++) {
|
|
||||||
floats[i] = view.getFloat32(HEADER_SIZE + i * 4, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { version, vertexCount, floats };
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.freeze({
|
|
||||||
decode,
|
|
||||||
VERSION, HEADER_SIZE, VERTEX_FLOATS, VERTEX_SIZE,
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<h1>Dusk Map Editor</h1>
|
|
||||||
<p class="text-muted">Browse and edit project assets.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<link rel="stylesheet" href="/map/map.css">
|
|
||||||
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex flex-wrap align-items-end gap-2 mb-2">
|
|
||||||
<div>
|
|
||||||
<label class="form-label mb-0">X</label>
|
|
||||||
<input type="number" id="coordX" class="form-control form-control-sm" style="width:5rem" value="0">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label mb-0">Y</label>
|
|
||||||
<input type="number" id="coordY" class="form-control form-control-sm" style="width:5rem" value="0">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label mb-0">Z</label>
|
|
||||||
<input type="number" id="coordZ" class="form-control form-control-sm" style="width:5rem" value="0">
|
|
||||||
</div>
|
|
||||||
<button id="btnLoad" type="button" class="btn btn-sm btn-secondary">Load</button>
|
|
||||||
|
|
||||||
<div class="dropdown">
|
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-outline-secondary dropdown-toggle"
|
|
||||||
type="button" data-bs-toggle="dropdown"
|
|
||||||
>Browse</button>
|
|
||||||
<ul class="dropdown-menu" id="chunkBrowseList"></ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dpad" title="Navigate to the neighboring chunk">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary dpad-n" id="navN" title="North (+Y)">N</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary dpad-w" id="navW" title="West (-X)">W</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary dpad-e" id="navE" title="East (+X)">E</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary dpad-s" id="navS" title="South (-Y)">S</button>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex flex-column gap-1">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="navUp" title="Up (+Z)">Up</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="navDown" title="Down (-Z)">Down</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="btnSave" type="button" class="btn btn-sm btn-primary ms-auto">
|
|
||||||
Save & Compile
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="statusLine" class="small text-muted mb-2"> </div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 col-lg-6">
|
|
||||||
<div class="d-flex align-items-center gap-2 mb-2">
|
|
||||||
<label class="form-label mb-0">Z-Level</label>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="zLevelDown">-</button>
|
|
||||||
<input type="range" id="zLevel" class="form-range" style="width:150px" min="0" max="3" value="0">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="zLevelUp">+</button>
|
|
||||||
<span id="zLevelLabel">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<div id="toolPalette" class="tile-palette"></div>
|
|
||||||
<div id="tilePalette" class="tile-palette"></div>
|
|
||||||
<canvas id="gridCanvas" class="border tile-grid" width="512" height="512"></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h5 class="mt-3">Meshes</h5>
|
|
||||||
<ul id="meshList" class="list-group mb-2"></ul>
|
|
||||||
<div class="d-flex gap-2 align-items-end flex-wrap">
|
|
||||||
<div style="min-width:220px">
|
|
||||||
<label class="form-label mb-0">Model</label>
|
|
||||||
<select id="meshPicker" class="form-select form-select-sm"></select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label mb-0">X</label>
|
|
||||||
<input id="meshX" type="number" class="form-control form-control-sm" style="width:5rem" value="0">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label mb-0">Y</label>
|
|
||||||
<input id="meshY" type="number" class="form-control form-control-sm" style="width:5rem" value="0">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label mb-0">Z</label>
|
|
||||||
<input id="meshZ" type="number" class="form-control form-control-sm" style="width:5rem" value="0">
|
|
||||||
</div>
|
|
||||||
<button id="btnAddMesh" type="button" class="btn btn-sm btn-outline-primary">Add</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 col-lg-6">
|
|
||||||
<p class="small text-muted mb-2">Preview (scroll to zoom)</p>
|
|
||||||
<canvas id="previewCanvas" class="border" width="512" height="512"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/common/dmf.js"></script>
|
|
||||||
<script src="/common/chunkterrain.js"></script>
|
|
||||||
<script src="/map/renderer.js"></script>
|
|
||||||
<script src="/map/map.js"></script>
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
/* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
.tile-palette {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
max-height: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-swatch {
|
|
||||||
position: relative;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-swatch.active {
|
|
||||||
border-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-swatch-icon {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dpad {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 2rem);
|
|
||||||
grid-template-rows: repeat(3, 2rem);
|
|
||||||
grid-template-areas:
|
|
||||||
". n ."
|
|
||||||
"w . e"
|
|
||||||
". s .";
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dpad-n { grid-area: n; }
|
|
||||||
.dpad-w { grid-area: w; }
|
|
||||||
.dpad-e { grid-area: e; }
|
|
||||||
.dpad-s { grid-area: s; }
|
|
||||||
.dpad button { padding: 0; }
|
|
||||||
|
|
||||||
.tile-grid {
|
|
||||||
image-rendering: pixelated;
|
|
||||||
cursor: crosshair;
|
|
||||||
touch-action: none;
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#previewCanvas {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
@@ -1,918 +0,0 @@
|
|||||||
// Copyright (c) 2026 Dominic Masters
|
|
||||||
//
|
|
||||||
// This software is released under the MIT License.
|
|
||||||
// https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
(() => {
|
|
||||||
const SHAPES = [
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_NULL, name: "Erase", color: "#20232a" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_GROUND, name: "Ground", color: "#4caf50" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_NORTH, name: "Ramp North", color: "#2196f3" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_EAST, name: "Ramp East", color: "#03a9f4" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTH, name: "Ramp South", color: "#00bcd4" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_WEST, name: "Ramp West", color: "#009688" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST, name: "Ramp NE", color: "#ff9800" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST, name: "Ramp NW", color: "#ff5722" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST, name: "Ramp SE", color: "#9c27b0" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST, name: "Ramp SW", color: "#e91e63" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST_INNER, name: "Ramp NE Inner", color: "#795548" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST_INNER, name: "Ramp NW Inner", color: "#607d8b" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST_INNER, name: "Ramp SE Inner", color: "#8bc34a" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST_INNER, name: "Ramp SW Inner", color: "#ffc107" },
|
|
||||||
];
|
|
||||||
const SHAPE_COLORS = Object.fromEntries(SHAPES.map((s) => [s.type, s.color]));
|
|
||||||
|
|
||||||
// Arrow direction per ramp, in canvas/screen space: north is up (dy < 0),
|
|
||||||
// east is right (dx > 0) - matches renderGrid()'s north-is-up convention.
|
|
||||||
const SHAPE_DIRECTIONS = {
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_NORTH]: { dx: 0, dy: -1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTH]: { dx: 0, dy: 1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_EAST]: { dx: 1, dy: 0 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_WEST]: { dx: -1, dy: 0 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST]: { dx: 1, dy: -1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST]: { dx: -1, dy: -1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST]: { dx: 1, dy: 1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST]: { dx: -1, dy: 1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST_INNER]: { dx: 1, dy: -1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST_INNER]: { dx: -1, dy: -1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST_INNER]: { dx: 1, dy: 1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST_INNER]: { dx: -1, dy: 1 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const INNER_CORNER_SHAPES = new Set([
|
|
||||||
ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST_INNER,
|
|
||||||
ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST_INNER,
|
|
||||||
ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST_INNER,
|
|
||||||
ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST_INNER,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Draws a single-headed arrow from (tailX, tailY) to (tipX, tipY).
|
|
||||||
function drawArrowSegment(ctx, tailX, tailY, tipX, tipY, size) {
|
|
||||||
ctx.strokeStyle = "#ffffff";
|
|
||||||
ctx.fillStyle = "#ffffff";
|
|
||||||
ctx.lineWidth = Math.max(1, size * 0.08);
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(tailX, tailY);
|
|
||||||
ctx.lineTo(tipX, tipY);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
const headLen = size * 0.18;
|
|
||||||
const angle = Math.atan2(tipY - tailY, tipX - tailX);
|
|
||||||
const leftAngle = angle + Math.PI * 0.8;
|
|
||||||
const rightAngle = angle - Math.PI * 0.8;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(tipX, tipY);
|
|
||||||
ctx.lineTo(tipX + Math.cos(leftAngle) * headLen, tipY + Math.sin(leftAngle) * headLen);
|
|
||||||
ctx.lineTo(tipX + Math.cos(rightAngle) * headLen, tipY + Math.sin(rightAngle) * headLen);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draws an arrow centered at (cx, cy) pointing toward the ramp's high
|
|
||||||
// side, so ramp direction is visible at a glance on both the palette
|
|
||||||
// swatches and the tile grid. Used for cardinal ramps and outer-corner
|
|
||||||
// ramps (a single raised corner).
|
|
||||||
function drawArrow(ctx, cx, cy, size, dx, dy) {
|
|
||||||
const mag = Math.hypot(dx, dy) || 1;
|
|
||||||
const ux = dx / mag, uy = dy / mag;
|
|
||||||
const len = size * 0.32;
|
|
||||||
drawArrowSegment(ctx, cx - ux * len, cy - uy * len, cx + ux * len, cy + uy * len, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draws a plain right-angle bracket - two line segments meeting at 90
|
|
||||||
// degrees at the tile's corner, one running along each of the two edges
|
|
||||||
// adjacent to it - so inner-corner ramps (three corners raised, one
|
|
||||||
// dropped) read as "the adjacent sides meeting in a 90-degree corner",
|
|
||||||
// distinct from the single diagonal arrow used for outer-corner ramps.
|
|
||||||
function drawCornerBracket(ctx, cx, cy, size, dx, dy) {
|
|
||||||
const cornerDist = size * 0.42;
|
|
||||||
const armLen = size * 0.34;
|
|
||||||
const cornerX = cx + dx * cornerDist;
|
|
||||||
const cornerY = cy + dy * cornerDist;
|
|
||||||
|
|
||||||
ctx.strokeStyle = "#ffffff";
|
|
||||||
ctx.lineWidth = Math.max(1, size * 0.08);
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
ctx.lineJoin = "round";
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(cornerX - dx * armLen, cornerY);
|
|
||||||
ctx.lineTo(cornerX, cornerY);
|
|
||||||
ctx.lineTo(cornerX, cornerY - dy * armLen);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draws the direction icon for a ramp tile `type` in a `size`x`size` area
|
|
||||||
// centered at (cx, cy): a diagonal arrow for cardinal/outer-corner ramps,
|
|
||||||
// or a right-angle bracket for inner-corner ramps. No-op for shapes with
|
|
||||||
// no direction (ground, erase).
|
|
||||||
function drawShapeIcon(ctx, cx, cy, size, type) {
|
|
||||||
const dir = SHAPE_DIRECTIONS[type];
|
|
||||||
if(!dir) return;
|
|
||||||
if(INNER_CORNER_SHAPES.has(type)) {
|
|
||||||
drawCornerBracket(ctx, cx, cy, size, dir.dx, dir.dy);
|
|
||||||
} else {
|
|
||||||
drawArrow(ctx, cx, cy, size, dir.dx, dir.dy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draws a small pencil icon centered in a `size`x`size` canvas.
|
|
||||||
function drawPencilIcon(ctx, size) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(size / 2, size / 2);
|
|
||||||
ctx.rotate(-Math.PI / 4);
|
|
||||||
|
|
||||||
const shaftW = size * 0.18;
|
|
||||||
const shaftH = size * 0.62;
|
|
||||||
|
|
||||||
ctx.fillStyle = "#e0e0e0";
|
|
||||||
ctx.fillRect(-shaftW / 2, shaftH * 0.28, shaftW, shaftH * 0.22);
|
|
||||||
|
|
||||||
ctx.fillStyle = "#f0c419";
|
|
||||||
ctx.fillRect(-shaftW / 2, -shaftH * 0.5, shaftW, shaftH * 0.78);
|
|
||||||
|
|
||||||
ctx.fillStyle = "#8d6e63";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(-shaftW / 2, -shaftH * 0.5);
|
|
||||||
ctx.lineTo(shaftW / 2, -shaftH * 0.5);
|
|
||||||
ctx.lineTo(0, -shaftH * 0.5 - shaftW);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draws a small paint bucket icon centered in a `size`x`size` canvas.
|
|
||||||
function drawBucketIcon(ctx, size) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(size / 2, size / 2);
|
|
||||||
|
|
||||||
const w = size * 0.5, h = size * 0.34;
|
|
||||||
|
|
||||||
ctx.strokeStyle = "#e0e0e0";
|
|
||||||
ctx.lineWidth = Math.max(1, size * 0.06);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(0, -h * 0.4, w * 0.4, Math.PI, 0);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.fillStyle = "#03a9f4";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(-w / 2, -h * 0.2);
|
|
||||||
ctx.lineTo(w / 2, -h * 0.2);
|
|
||||||
ctx.lineTo(w * 0.32, h * 0.6);
|
|
||||||
ctx.lineTo(-w * 0.32, h * 0.6);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(-w * 0.55, h * 0.75, size * 0.06, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOOLS = [
|
|
||||||
{ id: "pencil", name: "Pencil", draw: drawPencilIcon },
|
|
||||||
{ id: "bucket", name: "Paint Bucket", draw: drawBucketIcon },
|
|
||||||
];
|
|
||||||
|
|
||||||
let coord = { x: 0, y: 0, z: 0 };
|
|
||||||
let tiles = new Int32Array(ChunkTerrain.TILE_COUNT);
|
|
||||||
let meshes = [];
|
|
||||||
let chunkExists = false;
|
|
||||||
let currentZLevel = 0;
|
|
||||||
let selectedShape = ChunkTerrain.TILE_SHAPE_GROUND;
|
|
||||||
let zoomWorldH = 20;
|
|
||||||
let dirty = false;
|
|
||||||
let hoverTile = null;
|
|
||||||
let activeTool = "pencil";
|
|
||||||
|
|
||||||
let mapRenderer = null;
|
|
||||||
let terrainMesh = null;
|
|
||||||
let gridLinesMesh = null;
|
|
||||||
let terrainTexturePromise = null;
|
|
||||||
let modelIndex = null;
|
|
||||||
let neighborChunks = [];
|
|
||||||
const modelCache = new Map();
|
|
||||||
|
|
||||||
const UNDO_LIMIT = 100;
|
|
||||||
let undoStack = [];
|
|
||||||
|
|
||||||
function markDirty() {
|
|
||||||
dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotState() {
|
|
||||||
return {
|
|
||||||
tiles: tiles.slice(),
|
|
||||||
meshes: meshes.map((m) => ({ file: m.file, pos: m.pos.slice() })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushUndo() {
|
|
||||||
undoStack.push(snapshotState());
|
|
||||||
if(undoStack.length > UNDO_LIMIT) undoStack.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
function undo() {
|
|
||||||
if(!undoStack.length) return;
|
|
||||||
const snap = undoStack.pop();
|
|
||||||
tiles = snap.tiles;
|
|
||||||
meshes = snap.meshes;
|
|
||||||
dirty = true;
|
|
||||||
renderGrid();
|
|
||||||
renderMeshList();
|
|
||||||
renderPreview();
|
|
||||||
updateStatus("Undid last change.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns true if it's OK to proceed (no unsaved changes, or the user
|
|
||||||
// confirmed discarding them).
|
|
||||||
function confirmDiscardIfDirty() {
|
|
||||||
if(!dirty) return true;
|
|
||||||
return window.confirm("You have unsaved changes to this chunk. Discard them?");
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLine() {
|
|
||||||
return document.getElementById("statusLine");
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStatus(msg, isError) {
|
|
||||||
const el = statusLine();
|
|
||||||
el.textContent = msg;
|
|
||||||
el.classList.toggle("text-danger", !!isError);
|
|
||||||
el.classList.toggle("text-muted", !isError);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiReadJson(path) {
|
|
||||||
const res = await fetch(`/api/read?path=${encodeURIComponent(path)}`);
|
|
||||||
const data = await res.json();
|
|
||||||
return { ok: res.ok, data };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiWrite(path, content) {
|
|
||||||
return fetch(`/api/write?path=${encodeURIComponent(path)}`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ content }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiDelete(path) {
|
|
||||||
return fetch(`/api/delete?path=${encodeURIComponent(path)}`, { method: "POST" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiCompileChunk(x, y, z) {
|
|
||||||
const res = await fetch(`/api/compile-chunk?x=${x}&y=${y}&z=${z}`, { method: "POST" });
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiLs(path) {
|
|
||||||
const res = await fetch(`/api/ls?path=${encodeURIComponent(path)}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiFind(path, ext) {
|
|
||||||
const res = await fetch(`/api/find?path=${encodeURIComponent(path)}&ext=${encodeURIComponent(ext)}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64ToBuffer(base64) {
|
|
||||||
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
|
||||||
return bytes.buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadImageTexture(assetPath) {
|
|
||||||
const { ok, data } = await apiReadJson(assetPath);
|
|
||||||
if(!ok || data.error) return null;
|
|
||||||
const blob = new Blob([base64ToBuffer(data.content)], { type: "image/png" });
|
|
||||||
const bitmap = await createImageBitmap(blob);
|
|
||||||
return mapRenderer.createTextureFromImage(bitmap);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readDmfVertices(assetPath) {
|
|
||||||
const { ok, data } = await apiReadJson(assetPath);
|
|
||||||
if(!ok || data.error) throw new Error(`Failed to read ${assetPath}`);
|
|
||||||
return DMF.decode(base64ToBuffer(data.content)).floats;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureModelIndex() {
|
|
||||||
if(modelIndex) return modelIndex;
|
|
||||||
const res = await apiFind("assets/models", ".json");
|
|
||||||
modelIndex = new Map();
|
|
||||||
for(const file of res.files || []) {
|
|
||||||
const base = file.split("/").pop().replace(/\.json$/, "");
|
|
||||||
modelIndex.set(base, file);
|
|
||||||
}
|
|
||||||
return modelIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scales a mesh's stored [x, y, z] offset for preview, matching the
|
|
||||||
// Z scaling mapChunkLoaded() applies at runtime (chunk.meshOffsets[m][2]
|
|
||||||
// * WORLD_LAYER_HEIGHT) - x/y stay 1:1 since only Z is height-scaled.
|
|
||||||
function scaledMeshOffset(pos) {
|
|
||||||
return [pos[0], pos[1], pos[2] * ChunkTerrain.WORLD_LAYER_HEIGHT];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolves a chunk mesh entry's bare filename (e.g. "house_5_3.dmf") to
|
|
||||||
// its model JSON by basename, mirroring find_model() in
|
|
||||||
// tools/asset/chunk/__main__.py - the chunk JSON only ever stores a bare
|
|
||||||
// filename, not a path.
|
|
||||||
async function resolveModelForMeshFile(file) {
|
|
||||||
await ensureModelIndex();
|
|
||||||
const base = file.replace(/\.[^.]+$/, "");
|
|
||||||
const rel = modelIndex.get(base);
|
|
||||||
if(!rel) return null;
|
|
||||||
|
|
||||||
const modelPath = `assets/models/${rel}`;
|
|
||||||
if(modelCache.has(modelPath)) return modelCache.get(modelPath);
|
|
||||||
|
|
||||||
const promise = (async () => {
|
|
||||||
const { ok, data } = await apiReadJson(modelPath);
|
|
||||||
if(!ok || data.error) throw new Error(`Failed to read model ${modelPath}`);
|
|
||||||
const modelDef = JSON.parse(data.content);
|
|
||||||
const meshFloats = await readDmfVertices(`assets/${modelDef.mesh}`);
|
|
||||||
const mesh = mapRenderer.createMesh(meshFloats);
|
|
||||||
const texture = modelDef.texture ? await loadImageTexture(`assets/${modelDef.texture}`) : null;
|
|
||||||
const colorBytes = modelDef.color || [255, 255, 255, 255];
|
|
||||||
const color = colorBytes.map((c) => c / 255);
|
|
||||||
return { mesh, texture, color };
|
|
||||||
})();
|
|
||||||
modelCache.set(modelPath, promise);
|
|
||||||
return promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
function chunkPaths(x, y, z) {
|
|
||||||
return {
|
|
||||||
raw: `assetsraw/chunks/chunk_${x}_${y}_${z}.json`,
|
|
||||||
dcf: `assets/chunks/${x}_${y}_${z}.dcf`,
|
|
||||||
terrainDmf: `assets/meshes/chunks/chunk_${x}_${y}_${z}_0.dmf`,
|
|
||||||
terrainModel: `assets/models/chunks/chunk_${x}_${y}_${z}_0.json`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loads the 8 same-Z neighbors around `coord` (read-only, for context in
|
|
||||||
// the 3D preview) and pre-builds their render data so renderPreview()
|
|
||||||
// doesn't have to rebuild GL buffers on every paint stroke.
|
|
||||||
async function loadNeighborChunks() {
|
|
||||||
const offsets = [];
|
|
||||||
for(let dy = -1; dy <= 1; dy++) {
|
|
||||||
for(let dx = -1; dx <= 1; dx++) {
|
|
||||||
if(dx || dy) offsets.push({ dx, dy });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(offsets.map(async ({ dx, dy }) => {
|
|
||||||
const nx = coord.x + dx, ny = coord.y + dy, nz = coord.z;
|
|
||||||
const { ok, data } = await apiReadJson(chunkPaths(nx, ny, nz).raw);
|
|
||||||
if(!ok || data.error) return null;
|
|
||||||
|
|
||||||
const json = JSON.parse(data.content);
|
|
||||||
const neighborTiles = new Int32Array(ChunkTerrain.TILE_COUNT);
|
|
||||||
for(const t of json.tiles || []) {
|
|
||||||
const [tx, ty, tz] = t.pos;
|
|
||||||
neighborTiles[ChunkTerrain.tileIndex(tx, ty, tz)] = t.type;
|
|
||||||
}
|
|
||||||
const neighborMeshes = (json.meshes || []).map((m) => ({ file: m.file, pos: m.pos || [0, 0, 0] }));
|
|
||||||
|
|
||||||
const floats = ChunkTerrain.buildTerrainVerts(neighborTiles);
|
|
||||||
const terrain = floats.length ? { mesh: mapRenderer.createMesh(floats) } : null;
|
|
||||||
|
|
||||||
const models = [];
|
|
||||||
for(const m of neighborMeshes) {
|
|
||||||
try {
|
|
||||||
const resolved = await resolveModelForMeshFile(m.file);
|
|
||||||
if(resolved) models.push({ ...resolved, offset: scaledMeshOffset(m.pos) });
|
|
||||||
} catch(e) {
|
|
||||||
console.warn("Failed to load neighbor mesh preview", m.file, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
offset: [dx * ChunkTerrain.CHUNK_WIDTH, dy * ChunkTerrain.CHUNK_HEIGHT, 0],
|
|
||||||
terrain,
|
|
||||||
models,
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
|
|
||||||
neighborChunks = results.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadChunk(x, y, z) {
|
|
||||||
coord = { x, y, z };
|
|
||||||
tiles = new Int32Array(ChunkTerrain.TILE_COUNT);
|
|
||||||
meshes = [];
|
|
||||||
chunkExists = false;
|
|
||||||
|
|
||||||
const { ok, data } = await apiReadJson(chunkPaths(x, y, z).raw);
|
|
||||||
if(ok && !data.error) {
|
|
||||||
chunkExists = true;
|
|
||||||
const json = JSON.parse(data.content);
|
|
||||||
for(const t of json.tiles || []) {
|
|
||||||
const [tx, ty, tz] = t.pos;
|
|
||||||
tiles[ChunkTerrain.tileIndex(tx, ty, tz)] = t.type;
|
|
||||||
}
|
|
||||||
meshes = (json.meshes || []).map((m) => ({ file: m.file, pos: m.pos || [0, 0, 0] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadNeighborChunks();
|
|
||||||
dirty = false;
|
|
||||||
undoStack = [];
|
|
||||||
|
|
||||||
renderGrid();
|
|
||||||
renderMeshList();
|
|
||||||
renderPreview();
|
|
||||||
updateStatus(chunkExists
|
|
||||||
? `Loaded chunk_${x}_${y}_${z}.json`
|
|
||||||
: `New (empty) chunk at ${x}, ${y}, ${z} - paint a tile and save to create it.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveChunk() {
|
|
||||||
const tilesArr = [];
|
|
||||||
for(let z = 0; z < ChunkTerrain.CHUNK_DEPTH; z++) {
|
|
||||||
for(let y = 0; y < ChunkTerrain.CHUNK_HEIGHT; y++) {
|
|
||||||
for(let x = 0; x < ChunkTerrain.CHUNK_WIDTH; x++) {
|
|
||||||
const type = tiles[ChunkTerrain.tileIndex(x, y, z)];
|
|
||||||
if(type && type !== ChunkTerrain.TILE_SHAPE_NULL) {
|
|
||||||
tilesArr.push({ pos: [x, y, z], type, tile: 0 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const paths = chunkPaths(coord.x, coord.y, coord.z);
|
|
||||||
|
|
||||||
if(tilesArr.length === 0 && meshes.length === 0) {
|
|
||||||
await Promise.all(Object.values(paths).map((p) => apiDelete(p)));
|
|
||||||
chunkExists = false;
|
|
||||||
dirty = false;
|
|
||||||
updateStatus(`Chunk ${coord.x}, ${coord.y}, ${coord.z} is empty - deleted.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = { tiles: tilesArr, meshes: meshes.map((m) => ({ file: m.file, pos: m.pos })) };
|
|
||||||
await apiWrite(paths.raw, JSON.stringify(json, null, 2));
|
|
||||||
chunkExists = true;
|
|
||||||
dirty = false;
|
|
||||||
updateStatus("Saved. Compiling...");
|
|
||||||
|
|
||||||
const result = await apiCompileChunk(coord.x, coord.y, coord.z);
|
|
||||||
if(result.ok) {
|
|
||||||
updateStatus(`Compiled OK.\n${result.stdout}`);
|
|
||||||
} else {
|
|
||||||
updateStatus(`Compile FAILED (code ${result.returncode}):\n${result.stderr}`, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPalette() {
|
|
||||||
const el = document.getElementById("tilePalette");
|
|
||||||
el.innerHTML = "";
|
|
||||||
for(const s of SHAPES) {
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
btn.type = "button";
|
|
||||||
btn.className = "tile-swatch" + (s.type === selectedShape ? " active" : "");
|
|
||||||
btn.style.background = s.color;
|
|
||||||
btn.title = s.name;
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
selectedShape = s.type;
|
|
||||||
buildPalette();
|
|
||||||
});
|
|
||||||
|
|
||||||
if(SHAPE_DIRECTIONS[s.type]) {
|
|
||||||
const icon = document.createElement("canvas");
|
|
||||||
icon.width = 32;
|
|
||||||
icon.height = 32;
|
|
||||||
icon.className = "tile-swatch-icon";
|
|
||||||
drawShapeIcon(icon.getContext("2d"), 16, 16, 32, s.type);
|
|
||||||
btn.appendChild(icon);
|
|
||||||
}
|
|
||||||
|
|
||||||
el.appendChild(btn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildToolPalette() {
|
|
||||||
const el = document.getElementById("toolPalette");
|
|
||||||
el.innerHTML = "";
|
|
||||||
for(const tool of TOOLS) {
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
btn.type = "button";
|
|
||||||
btn.className = "tile-swatch" + (tool.id === activeTool ? " active" : "");
|
|
||||||
btn.style.background = "#2a2d33";
|
|
||||||
btn.title = tool.name;
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
activeTool = tool.id;
|
|
||||||
buildToolPalette();
|
|
||||||
});
|
|
||||||
|
|
||||||
const icon = document.createElement("canvas");
|
|
||||||
icon.width = 32;
|
|
||||||
icon.height = 32;
|
|
||||||
icon.className = "tile-swatch-icon";
|
|
||||||
tool.draw(icon.getContext("2d"), 32);
|
|
||||||
btn.appendChild(icon);
|
|
||||||
|
|
||||||
el.appendChild(btn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function canvasToTile(e, canvas) {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH;
|
|
||||||
const px = (e.clientX - rect.left) * (canvas.width / rect.width);
|
|
||||||
const py = (e.clientY - rect.top) * (canvas.height / rect.height);
|
|
||||||
const x = Math.floor(px / cell);
|
|
||||||
const yFromTop = Math.floor(py / cell);
|
|
||||||
const y = ChunkTerrain.CHUNK_HEIGHT - 1 - yFromTop;
|
|
||||||
if(x < 0 || x >= ChunkTerrain.CHUNK_WIDTH || y < 0 || y >= ChunkTerrain.CHUNK_HEIGHT) return null;
|
|
||||||
return { x, y };
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGrid() {
|
|
||||||
const canvas = document.getElementById("gridCanvas");
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH;
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
for(let y = 0; y < ChunkTerrain.CHUNK_HEIGHT; y++) {
|
|
||||||
for(let x = 0; x < ChunkTerrain.CHUNK_WIDTH; x++) {
|
|
||||||
const type = tiles[ChunkTerrain.tileIndex(x, y, currentZLevel)];
|
|
||||||
const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - y) * cell;
|
|
||||||
ctx.fillStyle = SHAPE_COLORS[type] || "#15171b";
|
|
||||||
ctx.fillRect(x * cell, sy, cell, cell);
|
|
||||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.25)";
|
|
||||||
ctx.strokeRect(x * cell, sy, cell, cell);
|
|
||||||
|
|
||||||
drawShapeIcon(ctx, x * cell + cell / 2, sy + cell / 2, cell, type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.fillStyle = "#ffffff";
|
|
||||||
for(const m of meshes) {
|
|
||||||
const [mx, my, mz] = m.pos;
|
|
||||||
if(Math.round(mz) !== currentZLevel) continue;
|
|
||||||
const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - my) * cell;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(mx * cell + cell / 2, sy + cell / 2, cell * 0.3, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
if(hoverTile) {
|
|
||||||
const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - hoverTile.y) * cell;
|
|
||||||
ctx.strokeStyle = "#ffeb3b";
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.strokeRect(hoverTile.x * cell + 1, sy + 1, cell - 2, cell - 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hitTestMesh(e, canvas) {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH;
|
|
||||||
const px = (e.clientX - rect.left) * (canvas.width / rect.width);
|
|
||||||
const py = (e.clientY - rect.top) * (canvas.height / rect.height);
|
|
||||||
|
|
||||||
let closest = -1;
|
|
||||||
let closestDist = cell * 0.5;
|
|
||||||
meshes.forEach((m, i) => {
|
|
||||||
const [mx, my, mz] = m.pos;
|
|
||||||
if(Math.round(mz) !== currentZLevel) return;
|
|
||||||
const cx = mx * cell + cell / 2;
|
|
||||||
const cy = (ChunkTerrain.CHUNK_HEIGHT - 1 - my) * cell + cell / 2;
|
|
||||||
const dist = Math.hypot(px - cx, py - cy);
|
|
||||||
if(dist < closestDist) { closestDist = dist; closest = i; }
|
|
||||||
});
|
|
||||||
return closest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveMeshTo(e, index) {
|
|
||||||
const canvas = document.getElementById("gridCanvas");
|
|
||||||
const t = canvasToTile(e, canvas);
|
|
||||||
if(!t) return;
|
|
||||||
meshes[index].pos[0] = t.x;
|
|
||||||
meshes[index].pos[1] = t.y;
|
|
||||||
markDirty();
|
|
||||||
renderGrid();
|
|
||||||
renderMeshList();
|
|
||||||
renderPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
function paintAt(e) {
|
|
||||||
const canvas = document.getElementById("gridCanvas");
|
|
||||||
const t = canvasToTile(e, canvas);
|
|
||||||
if(!t) return;
|
|
||||||
tiles[ChunkTerrain.tileIndex(t.x, t.y, currentZLevel)] = selectedShape;
|
|
||||||
markDirty();
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4-connected flood fill of same-type tiles at the current Z-level,
|
|
||||||
// starting from (startX, startY), replacing them with selectedShape.
|
|
||||||
function floodFill(startX, startY) {
|
|
||||||
const targetType = tiles[ChunkTerrain.tileIndex(startX, startY, currentZLevel)];
|
|
||||||
if(targetType === selectedShape) return;
|
|
||||||
|
|
||||||
const stack = [[startX, startY]];
|
|
||||||
const visited = new Set();
|
|
||||||
while(stack.length) {
|
|
||||||
const [x, y] = stack.pop();
|
|
||||||
const key = `${x},${y}`;
|
|
||||||
if(visited.has(key)) continue;
|
|
||||||
if(x < 0 || x >= ChunkTerrain.CHUNK_WIDTH || y < 0 || y >= ChunkTerrain.CHUNK_HEIGHT) continue;
|
|
||||||
visited.add(key);
|
|
||||||
|
|
||||||
const idx = ChunkTerrain.tileIndex(x, y, currentZLevel);
|
|
||||||
if(tiles[idx] !== targetType) continue;
|
|
||||||
tiles[idx] = selectedShape;
|
|
||||||
stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillAt(e) {
|
|
||||||
const canvas = document.getElementById("gridCanvas");
|
|
||||||
const t = canvasToTile(e, canvas);
|
|
||||||
if(!t) return;
|
|
||||||
pushUndo();
|
|
||||||
floodFill(t.x, t.y);
|
|
||||||
markDirty();
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
function rebuildTerrainMesh() {
|
|
||||||
const floats = ChunkTerrain.buildTerrainVerts(tiles);
|
|
||||||
terrainMesh = floats.length ? mapRenderer.createMesh(floats) : null;
|
|
||||||
|
|
||||||
const lineFloats = ChunkTerrain.buildGridLines(tiles);
|
|
||||||
gridLinesMesh = lineFloats.length ? mapRenderer.createMesh(lineFloats) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keeps the preview canvas's backing-store resolution matched to its
|
|
||||||
// actual displayed CSS size (times devicePixelRatio) - the canvas
|
|
||||||
// otherwise stays at its fixed width/height attributes (512x512) while
|
|
||||||
// CSS stretches it to fill the column, which upscales and blurs the
|
|
||||||
// WebGL output on wider or high-DPI screens.
|
|
||||||
function resizePreviewCanvas() {
|
|
||||||
const canvas = document.getElementById("previewCanvas");
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
const displayWidth = Math.max(1, Math.round(canvas.clientWidth * dpr));
|
|
||||||
const displayHeight = Math.max(1, Math.round(canvas.clientHeight * dpr));
|
|
||||||
if(canvas.width !== displayWidth || canvas.height !== displayHeight) {
|
|
||||||
canvas.width = displayWidth;
|
|
||||||
canvas.height = displayHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderPreview() {
|
|
||||||
if(!mapRenderer) return;
|
|
||||||
resizePreviewCanvas();
|
|
||||||
rebuildTerrainMesh();
|
|
||||||
|
|
||||||
const models = [];
|
|
||||||
for(const m of meshes) {
|
|
||||||
try {
|
|
||||||
const resolved = await resolveModelForMeshFile(m.file);
|
|
||||||
if(resolved) models.push({ ...resolved, offset: scaledMeshOffset(m.pos) });
|
|
||||||
} catch(e) {
|
|
||||||
console.warn("Failed to load mesh preview", m.file, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let terrainTexture = null;
|
|
||||||
if(terrainMesh || neighborChunks.some((n) => n.terrain)) {
|
|
||||||
if(!terrainTexturePromise) terrainTexturePromise = loadImageTexture("assets/tiles.png");
|
|
||||||
terrainTexture = await terrainTexturePromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
const neighbors = neighborChunks.map((n) => ({
|
|
||||||
offset: n.offset,
|
|
||||||
terrain: n.terrain ? { mesh: n.terrain.mesh, texture: terrainTexture } : null,
|
|
||||||
models: n.models,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const zLevelHeight = currentZLevel * ChunkTerrain.WORLD_LAYER_HEIGHT;
|
|
||||||
mapRenderer.render({
|
|
||||||
target: [ChunkTerrain.CHUNK_WIDTH / 2, ChunkTerrain.CHUNK_HEIGHT / 2, zLevelHeight],
|
|
||||||
worldH: zoomWorldH,
|
|
||||||
terrain: terrainMesh ? { mesh: terrainMesh, texture: terrainTexture } : null,
|
|
||||||
gridLines: gridLinesMesh ? { mesh: gridLinesMesh } : null,
|
|
||||||
models,
|
|
||||||
neighbors,
|
|
||||||
highlight: hoverTile ? { x: hoverTile.x, y: hoverTile.y, z: zLevelHeight } : null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMeshList() {
|
|
||||||
const ul = document.getElementById("meshList");
|
|
||||||
ul.innerHTML = "";
|
|
||||||
meshes.forEach((m, i) => {
|
|
||||||
const li = document.createElement("li");
|
|
||||||
li.className = "list-group-item d-flex align-items-center gap-2 flex-wrap";
|
|
||||||
|
|
||||||
const label = document.createElement("span");
|
|
||||||
label.className = "me-auto";
|
|
||||||
label.textContent = m.file;
|
|
||||||
li.appendChild(label);
|
|
||||||
|
|
||||||
["X", "Y", "Z"].forEach((axis, axisIndex) => {
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "number";
|
|
||||||
input.className = "form-control form-control-sm";
|
|
||||||
input.style.width = "5rem";
|
|
||||||
input.title = axis;
|
|
||||||
input.value = m.pos[axisIndex];
|
|
||||||
input.addEventListener("change", () => {
|
|
||||||
pushUndo();
|
|
||||||
m.pos[axisIndex] = parseFloat(input.value) || 0;
|
|
||||||
markDirty();
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
});
|
|
||||||
li.appendChild(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
btn.type = "button";
|
|
||||||
btn.className = "btn btn-sm btn-outline-danger";
|
|
||||||
btn.textContent = "Remove";
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
pushUndo();
|
|
||||||
meshes.splice(i, 1);
|
|
||||||
markDirty();
|
|
||||||
renderMeshList();
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
});
|
|
||||||
|
|
||||||
li.appendChild(btn);
|
|
||||||
ul.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildMeshPicker() {
|
|
||||||
await ensureModelIndex();
|
|
||||||
const select = document.getElementById("meshPicker");
|
|
||||||
select.innerHTML = "";
|
|
||||||
for(const [base, rel] of [...modelIndex.entries()].sort()) {
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = base;
|
|
||||||
opt.textContent = rel;
|
|
||||||
select.appendChild(opt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildChunkBrowseList() {
|
|
||||||
const list = document.getElementById("chunkBrowseList");
|
|
||||||
list.innerHTML = "";
|
|
||||||
const res = await apiLs("assetsraw/chunks");
|
|
||||||
for(const entry of res.entries || []) {
|
|
||||||
const m = entry.name.match(/^chunk_(-?\d+)_(-?\d+)_(-?\d+)\.json$/);
|
|
||||||
if(!m) continue;
|
|
||||||
const [, x, y, z] = m;
|
|
||||||
|
|
||||||
const li = document.createElement("li");
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.className = "dropdown-item";
|
|
||||||
a.href = "#";
|
|
||||||
a.textContent = `${x}, ${y}, ${z}`;
|
|
||||||
a.addEventListener("click", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
goToChunk(parseInt(x, 10), parseInt(y, 10), parseInt(z, 10));
|
|
||||||
});
|
|
||||||
|
|
||||||
li.appendChild(a);
|
|
||||||
list.appendChild(li);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCoordInputs(x, y, z) {
|
|
||||||
document.getElementById("coordX").value = x;
|
|
||||||
document.getElementById("coordY").value = y;
|
|
||||||
document.getElementById("coordZ").value = z;
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToChunk(x, y, z) {
|
|
||||||
if(!confirmDiscardIfDirty()) return;
|
|
||||||
setCoordInputs(x, y, z);
|
|
||||||
loadChunk(x, y, z);
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindEvents() {
|
|
||||||
const gridCanvas = document.getElementById("gridCanvas");
|
|
||||||
let painting = false;
|
|
||||||
let draggingMeshIndex = -1;
|
|
||||||
|
|
||||||
gridCanvas.addEventListener("mousedown", (e) => {
|
|
||||||
if(activeTool === "bucket") {
|
|
||||||
fillAt(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hit = hitTestMesh(e, gridCanvas);
|
|
||||||
pushUndo();
|
|
||||||
if(hit !== -1) {
|
|
||||||
draggingMeshIndex = hit;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
painting = true;
|
|
||||||
paintAt(e);
|
|
||||||
});
|
|
||||||
window.addEventListener("mouseup", () => { painting = false; draggingMeshIndex = -1; });
|
|
||||||
gridCanvas.addEventListener("mousemove", (e) => {
|
|
||||||
hoverTile = canvasToTile(e, gridCanvas);
|
|
||||||
if(draggingMeshIndex !== -1) { moveMeshTo(e, draggingMeshIndex); return; }
|
|
||||||
if(painting) { paintAt(e); return; }
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
});
|
|
||||||
gridCanvas.addEventListener("mouseleave", () => {
|
|
||||||
hoverTile = null;
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
});
|
|
||||||
|
|
||||||
function setZLevel(z) {
|
|
||||||
currentZLevel = Math.max(0, Math.min(ChunkTerrain.CHUNK_DEPTH - 1, z));
|
|
||||||
document.getElementById("zLevel").value = currentZLevel;
|
|
||||||
document.getElementById("zLevelLabel").textContent = currentZLevel;
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("zLevel").addEventListener("input", (e) => {
|
|
||||||
setZLevel(parseInt(e.target.value, 10));
|
|
||||||
});
|
|
||||||
document.getElementById("zLevelDown").addEventListener("click", () => setZLevel(currentZLevel - 1));
|
|
||||||
document.getElementById("zLevelUp").addEventListener("click", () => setZLevel(currentZLevel + 1));
|
|
||||||
|
|
||||||
document.getElementById("btnLoad").addEventListener("click", () => {
|
|
||||||
const x = parseInt(document.getElementById("coordX").value, 10) || 0;
|
|
||||||
const y = parseInt(document.getElementById("coordY").value, 10) || 0;
|
|
||||||
const z = parseInt(document.getElementById("coordZ").value, 10) || 0;
|
|
||||||
goToChunk(x, y, z);
|
|
||||||
});
|
|
||||||
|
|
||||||
// NORTH=+Y, EAST=+X (matches tile-shape convention used by the tile
|
|
||||||
// grid and tools/asset/chunk/__main__.py).
|
|
||||||
document.getElementById("navN").addEventListener("click", () => goToChunk(coord.x, coord.y + 1, coord.z));
|
|
||||||
document.getElementById("navS").addEventListener("click", () => goToChunk(coord.x, coord.y - 1, coord.z));
|
|
||||||
document.getElementById("navE").addEventListener("click", () => goToChunk(coord.x + 1, coord.y, coord.z));
|
|
||||||
document.getElementById("navW").addEventListener("click", () => goToChunk(coord.x - 1, coord.y, coord.z));
|
|
||||||
document.getElementById("navUp").addEventListener("click", () => goToChunk(coord.x, coord.y, coord.z + 1));
|
|
||||||
document.getElementById("navDown").addEventListener("click", () => goToChunk(coord.x, coord.y, coord.z - 1));
|
|
||||||
|
|
||||||
function runSave() {
|
|
||||||
saveChunk().catch((e) => updateStatus(`Save failed: ${e.message}`, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("btnSave").addEventListener("click", runSave);
|
|
||||||
|
|
||||||
document.getElementById("btnAddMesh").addEventListener("click", () => {
|
|
||||||
const base = document.getElementById("meshPicker").value;
|
|
||||||
if(!base) return;
|
|
||||||
const pos = [
|
|
||||||
parseFloat(document.getElementById("meshX").value) || 0,
|
|
||||||
parseFloat(document.getElementById("meshY").value) || 0,
|
|
||||||
parseFloat(document.getElementById("meshZ").value) || 0,
|
|
||||||
];
|
|
||||||
pushUndo();
|
|
||||||
meshes.push({ file: `${base}.dmf`, pos });
|
|
||||||
markDirty();
|
|
||||||
renderMeshList();
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("previewCanvas").addEventListener("wheel", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
zoomWorldH = Math.min(60, Math.max(4, zoomWorldH + Math.sign(e.deltaY)));
|
|
||||||
renderPreview();
|
|
||||||
}, { passive: false });
|
|
||||||
|
|
||||||
window.addEventListener("resize", () => renderPreview());
|
|
||||||
|
|
||||||
window.addEventListener("keydown", (e) => {
|
|
||||||
if(!(e.ctrlKey || e.metaKey)) return;
|
|
||||||
const key = e.key.toLowerCase();
|
|
||||||
if(key === "z") {
|
|
||||||
e.preventDefault();
|
|
||||||
undo();
|
|
||||||
} else if(key === "s") {
|
|
||||||
e.preventDefault();
|
|
||||||
runSave();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
|
||||||
buildPalette();
|
|
||||||
buildToolPalette();
|
|
||||||
mapRenderer = MapRenderer.create(document.getElementById("previewCanvas"));
|
|
||||||
bindEvents();
|
|
||||||
await Promise.all([buildChunkBrowseList(), buildMeshPicker()]);
|
|
||||||
await loadChunk(coord.x, coord.y, coord.z);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
// Copyright (c) 2026 Dominic Masters
|
|
||||||
//
|
|
||||||
// This software is released under the MIT License.
|
|
||||||
// https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// MapRenderer - a minimal, dependency-free WebGL renderer for the chunk
|
|
||||||
// preview pane. The camera formula is ported 1:1 from
|
|
||||||
// src/dusk/scene/overworld/sceneoverworld.c (lines 62-81) so the preview
|
|
||||||
// approximates the game's own fixed-angle 3rd-person camera: the eye sits
|
|
||||||
// worldH units "behind" the target along Y and a proportional distance
|
|
||||||
// "above" along Z, always looking at the target, up = (0, 1, 0).
|
|
||||||
//
|
|
||||||
// The material model mirrors SHADER_UNLIT: a texture sampler multiplied by
|
|
||||||
// a flat RGBA tint, with a 1x1 white fallback texture standing in for
|
|
||||||
// untextured models.
|
|
||||||
|
|
||||||
const MapRenderer = (() => {
|
|
||||||
const VERT_SRC = `
|
|
||||||
attribute vec2 aUV;
|
|
||||||
attribute vec3 aPos;
|
|
||||||
uniform mat4 uModel;
|
|
||||||
uniform mat4 uView;
|
|
||||||
uniform mat4 uProjection;
|
|
||||||
varying vec2 vUV;
|
|
||||||
void main() {
|
|
||||||
vUV = aUV;
|
|
||||||
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FRAG_SRC = `
|
|
||||||
precision mediump float;
|
|
||||||
varying vec2 vUV;
|
|
||||||
uniform sampler2D uTexture;
|
|
||||||
uniform vec4 uColor;
|
|
||||||
void main() {
|
|
||||||
gl_FragColor = texture2D(uTexture, vUV) * uColor;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function mat4Identity(out) {
|
|
||||||
out.fill(0);
|
|
||||||
out[0] = out[5] = out[10] = out[15] = 1;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mat4Translation(out, v) {
|
|
||||||
mat4Identity(out);
|
|
||||||
out[12] = v[0];
|
|
||||||
out[13] = v[1];
|
|
||||||
out[14] = v[2];
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mat4Perspective(out, fovy, aspect, near, far) {
|
|
||||||
const f = 1.0 / Math.tan(fovy / 2);
|
|
||||||
out.fill(0);
|
|
||||||
out[0] = f / aspect;
|
|
||||||
out[5] = f;
|
|
||||||
out[10] = (far + near) / (near - far);
|
|
||||||
out[11] = -1;
|
|
||||||
out[14] = (2 * far * near) / (near - far);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function vec3Sub(a, b) {
|
|
||||||
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
||||||
}
|
|
||||||
|
|
||||||
function vec3Cross(a, b) {
|
|
||||||
return [
|
|
||||||
a[1] * b[2] - a[2] * b[1],
|
|
||||||
a[2] * b[0] - a[0] * b[2],
|
|
||||||
a[0] * b[1] - a[1] * b[0],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function vec3Normalize(a) {
|
|
||||||
const len = Math.hypot(a[0], a[1], a[2]) || 1;
|
|
||||||
return [a[0] / len, a[1] / len, a[2] / len];
|
|
||||||
}
|
|
||||||
|
|
||||||
function mat4LookAt(out, eye, center, up) {
|
|
||||||
const zAxis = vec3Normalize(vec3Sub(eye, center));
|
|
||||||
const xAxis = vec3Normalize(vec3Cross(up, zAxis));
|
|
||||||
const yAxis = vec3Cross(zAxis, xAxis);
|
|
||||||
|
|
||||||
out[0] = xAxis[0]; out[1] = yAxis[0]; out[2] = zAxis[0]; out[3] = 0;
|
|
||||||
out[4] = xAxis[1]; out[5] = yAxis[1]; out[6] = zAxis[1]; out[7] = 0;
|
|
||||||
out[8] = xAxis[2]; out[9] = yAxis[2]; out[10] = zAxis[2]; out[11] = 0;
|
|
||||||
out[12] = -(xAxis[0] * eye[0] + xAxis[1] * eye[1] + xAxis[2] * eye[2]);
|
|
||||||
out[13] = -(yAxis[0] * eye[0] + yAxis[1] * eye[1] + yAxis[2] * eye[2]);
|
|
||||||
out[14] = -(zAxis[0] * eye[0] + zAxis[1] * eye[1] + zAxis[2] * eye[2]);
|
|
||||||
out[15] = 1;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ported from sceneoverworld.c:62-81. Returns { eye, view }.
|
|
||||||
function computeCamera(target, worldH) {
|
|
||||||
const fov = Math.PI / 4; // glm_rad(45.0f)
|
|
||||||
const zDist = (worldH * 0.5) / Math.tan(fov * 0.5);
|
|
||||||
const offset = -worldH;
|
|
||||||
|
|
||||||
const eye = [target[0], target[1] + offset, target[2] + zDist];
|
|
||||||
const view = mat4LookAt(new Float32Array(16), eye, target, [0, 1, 0]);
|
|
||||||
return { eye, view, fov };
|
|
||||||
}
|
|
||||||
|
|
||||||
function compileShader(gl, type, src) {
|
|
||||||
const shader = gl.createShader(type);
|
|
||||||
gl.shaderSource(shader, src);
|
|
||||||
gl.compileShader(shader);
|
|
||||||
if(!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
||||||
const info = gl.getShaderInfoLog(shader);
|
|
||||||
gl.deleteShader(shader);
|
|
||||||
throw new Error(`Shader compile failed: ${info}`);
|
|
||||||
}
|
|
||||||
return shader;
|
|
||||||
}
|
|
||||||
|
|
||||||
function create(canvas) {
|
|
||||||
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
|
||||||
if(!gl) throw new Error("WebGL is not available in this browser");
|
|
||||||
|
|
||||||
const program = gl.createProgram();
|
|
||||||
gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, VERT_SRC));
|
|
||||||
gl.attachShader(program, compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SRC));
|
|
||||||
gl.linkProgram(program);
|
|
||||||
if(!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
||||||
throw new Error(`Program link failed: ${gl.getProgramInfoLog(program)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const attribs = {
|
|
||||||
aUV: gl.getAttribLocation(program, "aUV"),
|
|
||||||
aPos: gl.getAttribLocation(program, "aPos"),
|
|
||||||
};
|
|
||||||
const uniforms = {
|
|
||||||
uModel: gl.getUniformLocation(program, "uModel"),
|
|
||||||
uView: gl.getUniformLocation(program, "uView"),
|
|
||||||
uProjection: gl.getUniformLocation(program, "uProjection"),
|
|
||||||
uTexture: gl.getUniformLocation(program, "uTexture"),
|
|
||||||
uColor: gl.getUniformLocation(program, "uColor"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const whiteTexture = gl.createTexture();
|
|
||||||
gl.bindTexture(gl.TEXTURE_2D, whiteTexture);
|
|
||||||
gl.texImage2D(
|
|
||||||
gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
|
|
||||||
new Uint8Array([255, 255, 255, 255]),
|
|
||||||
);
|
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
||||||
|
|
||||||
gl.enable(gl.DEPTH_TEST);
|
|
||||||
gl.enable(gl.BLEND);
|
|
||||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
||||||
// Terrain/mesh winding isn't guaranteed consistent relative to this
|
|
||||||
// preview's fixed camera angle, so backface culling stays off - this
|
|
||||||
// is an authoring preview, not a performance-sensitive renderer, and
|
|
||||||
// an invisible mesh is a worse failure mode than a visible backface.
|
|
||||||
|
|
||||||
// Uploads interleaved [u, v, x, y, z] float data (DMF/terrain layout)
|
|
||||||
// into a GL buffer. Returns { buffer, vertexCount }.
|
|
||||||
function createMesh(floats) {
|
|
||||||
const buffer = gl.createBuffer();
|
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, floats, gl.STATIC_DRAW);
|
|
||||||
return { buffer, vertexCount: floats.length / 5 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTextureFromImage(image) {
|
|
||||||
const texture = gl.createTexture();
|
|
||||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
||||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
||||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
|
||||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
||||||
return texture;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawMesh(mesh, model, texture, color, depthWrite, mode) {
|
|
||||||
gl.depthMask(depthWrite !== false);
|
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer);
|
|
||||||
gl.enableVertexAttribArray(attribs.aUV);
|
|
||||||
gl.vertexAttribPointer(attribs.aUV, 2, gl.FLOAT, false, 20, 0);
|
|
||||||
gl.enableVertexAttribArray(attribs.aPos);
|
|
||||||
gl.vertexAttribPointer(attribs.aPos, 3, gl.FLOAT, false, 20, 8);
|
|
||||||
|
|
||||||
gl.uniformMatrix4fv(uniforms.uModel, false, model);
|
|
||||||
gl.uniform4f(uniforms.uColor, color[0], color[1], color[2], color[3]);
|
|
||||||
|
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
|
||||||
gl.bindTexture(gl.TEXTURE_2D, texture || whiteTexture);
|
|
||||||
gl.uniform1i(uniforms.uTexture, 0);
|
|
||||||
|
|
||||||
gl.drawArrays(mode || gl.TRIANGLES, 0, mesh.vertexCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Builds a 4-vertex outline of the tile at (x, y, z), lifted slightly
|
|
||||||
// to avoid z-fighting with the terrain surface beneath it.
|
|
||||||
function buildHighlightMesh(x, y, z) {
|
|
||||||
const eps = 0.02;
|
|
||||||
return createMesh(new Float32Array([
|
|
||||||
0, 0, x, y, z + eps,
|
|
||||||
0, 0, x + 1, y, z + eps,
|
|
||||||
0, 0, x + 1, y + 1, z + eps,
|
|
||||||
0, 0, x, y + 1, z + eps,
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neighboring chunks are drawn dimmer and semi-transparent, so they
|
|
||||||
// read as context rather than part of the chunk being edited.
|
|
||||||
const NEIGHBOR_ALPHA = 0.35;
|
|
||||||
const NEIGHBOR_DARKEN = 0.5;
|
|
||||||
|
|
||||||
// scene = {
|
|
||||||
// target: [x, y, z], worldH: number,
|
|
||||||
// terrain: { mesh, texture } | null,
|
|
||||||
// gridLines: { mesh } | null,
|
|
||||||
// models: [{ mesh, texture, color: [r,g,b,a in 0..1], offset: [x,y,z] }],
|
|
||||||
// neighbors: [{
|
|
||||||
// offset: [x,y,z],
|
|
||||||
// terrain: { mesh, texture } | null,
|
|
||||||
// models: [{ mesh, texture, color, offset }],
|
|
||||||
// }],
|
|
||||||
// highlight: { x, y, z } | null,
|
|
||||||
// }
|
|
||||||
function render(scene) {
|
|
||||||
const width = canvas.width, height = canvas.height;
|
|
||||||
gl.viewport(0, 0, width, height);
|
|
||||||
gl.clearColor(0.16, 0.18, 0.22, 1);
|
|
||||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
||||||
gl.useProgram(program);
|
|
||||||
gl.depthMask(true);
|
|
||||||
|
|
||||||
const { view, fov } = computeCamera(scene.target, scene.worldH);
|
|
||||||
const projection = mat4Perspective(new Float32Array(16), fov, width / height, 0.1, 1000);
|
|
||||||
gl.uniformMatrix4fv(uniforms.uProjection, false, projection);
|
|
||||||
gl.uniformMatrix4fv(uniforms.uView, false, view);
|
|
||||||
|
|
||||||
const identity = mat4Identity(new Float32Array(16));
|
|
||||||
|
|
||||||
if(scene.terrain) {
|
|
||||||
drawMesh(scene.terrain.mesh, identity, scene.terrain.texture, [1, 1, 1, 1], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(scene.gridLines) {
|
|
||||||
drawMesh(scene.gridLines.mesh, identity, null, [0, 0, 0, 0.35], false, gl.LINES);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const model of scene.models) {
|
|
||||||
const modelMatrix = mat4Translation(new Float32Array(16), model.offset);
|
|
||||||
drawMesh(model.mesh, modelMatrix, model.texture, model.color, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const neighbor of scene.neighbors || []) {
|
|
||||||
if(neighbor.terrain) {
|
|
||||||
const modelMatrix = mat4Translation(new Float32Array(16), neighbor.offset);
|
|
||||||
drawMesh(
|
|
||||||
neighbor.terrain.mesh, modelMatrix, neighbor.terrain.texture,
|
|
||||||
[NEIGHBOR_DARKEN, NEIGHBOR_DARKEN, NEIGHBOR_DARKEN, NEIGHBOR_ALPHA], false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const model of neighbor.models) {
|
|
||||||
const worldOffset = [
|
|
||||||
neighbor.offset[0] + model.offset[0],
|
|
||||||
neighbor.offset[1] + model.offset[1],
|
|
||||||
neighbor.offset[2] + model.offset[2],
|
|
||||||
];
|
|
||||||
const modelMatrix = mat4Translation(new Float32Array(16), worldOffset);
|
|
||||||
const color = [
|
|
||||||
model.color[0] * NEIGHBOR_DARKEN,
|
|
||||||
model.color[1] * NEIGHBOR_DARKEN,
|
|
||||||
model.color[2] * NEIGHBOR_DARKEN,
|
|
||||||
model.color[3] * NEIGHBOR_ALPHA,
|
|
||||||
];
|
|
||||||
drawMesh(model.mesh, modelMatrix, model.texture, color, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if(scene.highlight) {
|
|
||||||
const { x, y, z } = scene.highlight;
|
|
||||||
const highlightMesh = buildHighlightMesh(x, y, z);
|
|
||||||
drawMesh(highlightMesh, identity, null, [1, 0.92, 0.23, 1], false, gl.LINE_LOOP);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { gl, createMesh, createTextureFromImage, render };
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.freeze({ create, computeCamera });
|
|
||||||
})();
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user