From 8fb1c9fb428679a58753eb96831cc9fe4963c36e Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Fri, 17 Jul 2026 21:06:21 -0500 Subject: [PATCH] physics and rendering fixes --- src/dusk/display/shader/shader.c | 1 + src/dusk/rpg/overworld/tileshape.c | 42 ++++++ src/dusk/rpg/overworld/tileshape.h | 21 ++- src/dusk/rpg/physics/physicsworld.c | 174 ++++++++++++++++++---- src/dusk/rpg/physics/physicsworld.h | 57 ++++++- src/dusk/ui/debug/uifps.c | 4 +- src/dusk/ui/debug/uiplayerpos.c | 3 +- src/dusk/ui/frame/backpack/uibackpack.c | 2 - src/dusk/ui/frame/game/uigamemenu.c | 2 - src/dusk/ui/frame/settings/uisettings.c | 2 - src/dusk/ui/frame/uiconfirm.c | 2 - src/dusk/ui/overlay/uicrop.c | 2 +- src/dusk/ui/overlay/uifullbox.c | 2 +- src/dusk/ui/overlay/uiloading.c | 3 +- src/dusk/ui/rpg/textbox/uitextbox.c | 2 - src/dusk/ui/rpg/textbox/uitextboxmain.c | 1 - src/dusk/ui/rpg/uiemoji.c | 1 - src/dusk/ui/transition/uitransitionfade.c | 2 +- src/dusk/ui/ui.c | 7 +- src/dusk/ui/widget/uislider.c | 3 - src/dusk/ui/widget/uitab.c | 1 - src/duskgl/display/mesh/meshgl.c | 10 +- test/rpg/overworld/CMakeLists.txt | 1 + test/rpg/overworld/test_tileshape.c | 140 +++++++++++++++++ test/rpg/physics/test_physicsworld.c | 84 ++++++++++- 25 files changed, 491 insertions(+), 78 deletions(-) create mode 100644 test/rpg/overworld/test_tileshape.c diff --git a/src/dusk/display/shader/shader.c b/src/dusk/display/shader/shader.c index 92c6fb09..9c9f3d32 100644 --- a/src/dusk/display/shader/shader.c +++ b/src/dusk/display/shader/shader.c @@ -21,6 +21,7 @@ errorret_t shaderInit(shader_t *shader, const shaderdefinition_t *def) { errorret_t shaderBind(shader_t *shader) { assertNotNull(shader, "Shader cannot be null"); + if(bound == shader) errorOk(); errorChain(shaderBindPlatform(shader)); bound = shader; errorOk(); diff --git a/src/dusk/rpg/overworld/tileshape.c b/src/dusk/rpg/overworld/tileshape.c index 13124c6f..3493795e 100644 --- a/src/dusk/rpg/overworld/tileshape.c +++ b/src/dusk/rpg/overworld/tileshape.c @@ -6,6 +6,28 @@ */ #include "tileshape.h" +#include "assert/assert.h" + +// Per-shape corner heights as [sw, se, ne, nw] offsets (0 or 1) from the +// tile's base Z layer - transcribed verbatim from the mesh generator's +// ramp tables (tools/asset/chunk/__main__.py _RAMP_CORNERS, +// editor/client/public/common/chunkterrain.js RAMP_CORNERS) so the +// walkable surface always matches what's rendered. +static const float_t TILE_SHAPE_RAMP_CORNERS[TILE_SHAPE_COUNT][4] = { + [TILE_SHAPE_GROUND] = { 0.0f, 0.0f, 0.0f, 0.0f }, + [TILE_SHAPE_RAMP_NORTH] = { 0.0f, 0.0f, 1.0f, 1.0f }, + [TILE_SHAPE_RAMP_SOUTH] = { 1.0f, 1.0f, 0.0f, 0.0f }, + [TILE_SHAPE_RAMP_EAST] = { 0.0f, 1.0f, 1.0f, 0.0f }, + [TILE_SHAPE_RAMP_WEST] = { 1.0f, 0.0f, 0.0f, 1.0f }, + [TILE_SHAPE_RAMP_NORTHEAST] = { 0.0f, 0.0f, 1.0f, 0.0f }, + [TILE_SHAPE_RAMP_NORTHWEST] = { 0.0f, 0.0f, 0.0f, 1.0f }, + [TILE_SHAPE_RAMP_SOUTHEAST] = { 0.0f, 1.0f, 0.0f, 0.0f }, + [TILE_SHAPE_RAMP_SOUTHWEST] = { 1.0f, 0.0f, 0.0f, 0.0f }, + [TILE_SHAPE_RAMP_NORTHEAST_INNER] = { 0.0f, 1.0f, 1.0f, 1.0f }, + [TILE_SHAPE_RAMP_NORTHWEST_INNER] = { 1.0f, 0.0f, 1.0f, 1.0f }, + [TILE_SHAPE_RAMP_SOUTHEAST_INNER] = { 1.0f, 1.0f, 1.0f, 0.0f }, + [TILE_SHAPE_RAMP_SOUTHWEST_INNER] = { 1.0f, 1.0f, 0.0f, 1.0f }, +}; bool_t tileShapeIsWalkable(const tileshape_t shape) { switch(shape) { @@ -36,4 +58,24 @@ bool_t tileShapeIsRamp(const tileshape_t shape) { default: return false; } +} + +float_t tileShapeGetRampHeight( + const tileshape_t shape, const float_t localX, const float_t localY +) { + assertTrue(localX >= 0.0f && localX <= 1.0f, "localX must be in [0,1]"); + assertTrue(localY >= 0.0f && localY <= 1.0f, "localY must be in [0,1]"); + + const float_t sw = TILE_SHAPE_RAMP_CORNERS[shape][0]; + const float_t se = TILE_SHAPE_RAMP_CORNERS[shape][1]; + const float_t ne = TILE_SHAPE_RAMP_CORNERS[shape][2]; + const float_t nw = TILE_SHAPE_RAMP_CORNERS[shape][3]; + + // Planar interpolation per triangle, split along the SW-NE diagonal - + // matches the two-triangle quad the mesh generator emits exactly, + // rather than a smooth (but unrendered) bilinear blend. + if(localY <= localX) { + return sw + (se - sw) * localX + (ne - se) * localY; + } + return sw + (ne - nw) * localX + (nw - sw) * localY; } \ No newline at end of file diff --git a/src/dusk/rpg/overworld/tileshape.h b/src/dusk/rpg/overworld/tileshape.h index 1187ac70..14d0fb97 100644 --- a/src/dusk/rpg/overworld/tileshape.h +++ b/src/dusk/rpg/overworld/tileshape.h @@ -38,8 +38,25 @@ bool_t tileShapeIsRamp(const tileshape_t shape); /** * Returns whether or not the given tile shape is walkable. - * + * * @param shape The tile shape to check. * @return bool_t True if walkable, false if not. */ -bool_t tileShapeIsWalkable(const tileshape_t shape); \ No newline at end of file +bool_t tileShapeIsWalkable(const tileshape_t shape); + +/** + * Returns the floor height offset (0 to 1, above the tile's base Z + * layer) for the given tile shape at a local position within the tile. + * Flat ground is always 0. Ramp shapes vary linearly across the tile, + * matching the two-triangle mesh generated for rendering (split along + * the SW-NE diagonal) exactly, so the walkable surface and the + * rendered surface never disagree. + * + * @param shape The tile shape to query. + * @param localX Local X position within the tile, in [0, 1]. + * @param localY Local Y position within the tile, in [0, 1]. + * @return float_t The height offset above the tile's base Z layer. + */ +float_t tileShapeGetRampHeight( + const tileshape_t shape, const float_t localX, const float_t localY +); \ No newline at end of file diff --git a/src/dusk/rpg/physics/physicsworld.c b/src/dusk/rpg/physics/physicsworld.c index c3fce02f..56e148b9 100644 --- a/src/dusk/rpg/physics/physicsworld.c +++ b/src/dusk/rpg/physics/physicsworld.c @@ -81,8 +81,13 @@ void physicsWorldResolveAxisX( for(worldunit_t col = oldColMax + 1; col <= newColMax; col++) { bool_t blocked = false; for(worldunit_t y = yStart; y <= yEnd; y++) { - const worldpos_t pos = { col, y, layerZ }; - if(!tileShapeIsWalkable(mapGetTile(pos).shape)) { + worldunit_t unusedBase; + tileshape_t unusedShape; + if( + !physicsWorldFindColumnTile( + col, y, layerZ, true, &unusedBase, &unusedShape + ) + ) { blocked = true; break; } @@ -107,8 +112,13 @@ void physicsWorldResolveAxisX( for(worldunit_t col = oldColMin - 1; col >= newColMin; col--) { bool_t blocked = false; for(worldunit_t y = yStart; y <= yEnd; y++) { - const worldpos_t pos = { col, y, layerZ }; - if(!tileShapeIsWalkable(mapGetTile(pos).shape)) { + worldunit_t unusedBase; + tileshape_t unusedShape; + if( + !physicsWorldFindColumnTile( + col, y, layerZ, true, &unusedBase, &unusedShape + ) + ) { blocked = true; break; } @@ -159,8 +169,13 @@ void physicsWorldResolveAxisY( for(worldunit_t row = oldRowMax + 1; row <= newRowMax; row++) { bool_t blocked = false; for(worldunit_t x = xStart; x <= xEnd; x++) { - const worldpos_t pos = { x, row, layerZ }; - if(!tileShapeIsWalkable(mapGetTile(pos).shape)) { + worldunit_t unusedBase; + tileshape_t unusedShape; + if( + !physicsWorldFindColumnTile( + x, row, layerZ, true, &unusedBase, &unusedShape + ) + ) { blocked = true; break; } @@ -185,8 +200,13 @@ void physicsWorldResolveAxisY( for(worldunit_t row = oldRowMin - 1; row >= newRowMin; row--) { bool_t blocked = false; for(worldunit_t x = xStart; x <= xEnd; x++) { - const worldpos_t pos = { x, row, layerZ }; - if(!tileShapeIsWalkable(mapGetTile(pos).shape)) { + worldunit_t unusedBase; + tileshape_t unusedShape; + if( + !physicsWorldFindColumnTile( + x, row, layerZ, true, &unusedBase, &unusedShape + ) + ) { blocked = true; break; } @@ -216,9 +236,6 @@ void physicsWorldResolveAxisZ( assertNotNull(world, "world must not be null"); assertNotNull(body, "body must not be null"); - const float_t vz = body->velocity[2]; - if(vz == 0.0f) return; - vec3 min, max; physicsBodyGetBounds(body, min, max); @@ -226,22 +243,86 @@ void physicsWorldResolveAxisZ( const worldunit_t xEnd = (worldunit_t)floorf(max[0] - PHYSICS_EPSILON); const worldunit_t yStart = (worldunit_t)floorf(min[1] + PHYSICS_EPSILON); const worldunit_t yEnd = (worldunit_t)floorf(max[1] - PHYSICS_EPSILON); + const worldunit_t layer = + (worldunit_t)floorf(body->position[2] + PHYSICS_EPSILON); + const float_t vz = body->velocity[2]; - if(vz < 0.0f) { - const worldunit_t layer = - (worldunit_t)floorf(body->position[2] + PHYSICS_EPSILON); + // Whether a column not directly at the current layer should still + // count as walkable if its own tile sits one layer above or below - + // each column only ever has one real tile, so this is what lets a + // column just crossed into horizontally (see the +/-1 layer check in + // ResolveAxisX/Y) be recognised here too, whether its tile sits one + // layer above (finishing a climb) or below (starting a descent). + // Gating on horizontal movement (rather than vz's sign) keeps this + // from ever mistaking a genuine ceiling directly above a stationary + // body - e.g. the frame right after being blocked by one, when vz has + // just been zeroed - for ground to snap up onto. + const bool_t movingHorizontally = + body->velocity[0] != 0.0f || body->velocity[1] != 0.0f; - bool_t solid = true; - for(worldunit_t x = xStart; x <= xEnd && solid; x++) { - for(worldunit_t y = yStart; y <= yEnd && solid; y++) { - const worldpos_t pos = { x, y, layer }; - if(!tileShapeIsWalkable(mapGetTile(pos).shape)) solid = false; + // Walkability across the whole footprint - every spanned column must + // have a walkable tile (at the current layer, or the +/-1 fallback + // above) for this layer to count as solid, same as before ramps. + bool_t solid = true; + for(worldunit_t x = xStart; x <= xEnd && solid; x++) { + for(worldunit_t y = yStart; y <= yEnd && solid; y++) { + worldunit_t unusedBase; + tileshape_t shape; + if( + !physicsWorldFindColumnTile( + x, y, layer, movingHorizontally, &unusedBase, &shape + ) + ) { + solid = false; } } + } + // Ground/ramp height comes from a single reference column - the + // footprint's center, not the tallest of every spanned column. A 1- + // wide body sitting at a non-integer position always straddles two + // columns (e.g. [1.01, 2.01)); using the tallest of them would yank a + // body barely touching a taller neighbouring column onto its full + // height, rather than the column it's actually standing on. + float_t groundHeight = (float_t)layer; + if(solid) { + const float_t centerX = body->position[0] + body->extents[0] * 0.5f; + const float_t centerY = body->position[1] + body->extents[1] * 0.5f; + const worldunit_t centerCol = + (worldunit_t)floorf(centerX + PHYSICS_EPSILON); + const worldunit_t centerRow = + (worldunit_t)floorf(centerY + PHYSICS_EPSILON); + + worldunit_t tileBase; + tileshape_t shape; + physicsWorldFindColumnTile( + centerCol, centerRow, layer, movingHorizontally, &tileBase, &shape + ); + + const float_t localX = mathClamp(centerX - centerCol, 0.0f, 1.0f); + const float_t localY = mathClamp(centerY - centerRow, 0.0f, 1.0f); + groundHeight = + (float_t)tileBase + tileShapeGetRampHeight(shape, localX, localY); + } + + // Standing on, or embedded below, the ground/ramp surface - snap up to + // it regardless of vertical velocity direction. Gravity only ever + // pulls down, so walking onto a rising ramp needs this explicit lift + // rather than just a fall-clamp. + if(solid && body->position[2] < groundHeight) { + body->position[2] = groundHeight; + if(vz <= 0.0f) body->velocity[2] = 0.0f; + body->grounded = true; + physicsWorldResolveBodyOverlap(body, 2, others, othersCount); + return; + } + + if(vz == 0.0f) return; + + if(vz < 0.0f) { const float_t newZ = body->position[2] + vz * dt; - if(solid && newZ < (float_t)layer) { - body->position[2] = (float_t)layer; + if(solid && newZ < groundHeight) { + body->position[2] = groundHeight; body->velocity[2] = 0.0f; body->grounded = true; } else { @@ -250,19 +331,19 @@ void physicsWorldResolveAxisZ( } } else { const float_t head = max[2]; - const worldunit_t layer = (worldunit_t)ceilf(head - PHYSICS_EPSILON); + const worldunit_t ceilLayer = (worldunit_t)ceilf(head - PHYSICS_EPSILON); - bool_t solid = true; - for(worldunit_t x = xStart; x <= xEnd && solid; x++) { - for(worldunit_t y = yStart; y <= yEnd && solid; y++) { - const worldpos_t pos = { x, y, layer }; - if(!tileShapeIsWalkable(mapGetTile(pos).shape)) solid = false; + bool_t ceilingSolid = true; + for(worldunit_t x = xStart; x <= xEnd && ceilingSolid; x++) { + for(worldunit_t y = yStart; y <= yEnd && ceilingSolid; y++) { + const worldpos_t pos = { x, y, ceilLayer }; + if(!tileShapeIsWalkable(mapGetTile(pos).shape)) ceilingSolid = false; } } const float_t newHead = head + vz * dt; - if(solid && newHead > (float_t)layer) { - body->position[2] = (float_t)layer - body->extents[2]; + if(ceilingSolid && newHead > (float_t)ceilLayer) { + body->position[2] = (float_t)ceilLayer - body->extents[2]; body->velocity[2] = 0.0f; } else { body->position[2] += vz * dt; @@ -272,6 +353,41 @@ void physicsWorldResolveAxisZ( physicsWorldResolveBodyOverlap(body, 2, others, othersCount); } +bool_t physicsWorldFindColumnTile( + const worldunit_t x, + const worldunit_t y, + const worldunit_t layer, + const bool_t movingHorizontally, + worldunit_t *outBase, + tileshape_t *outShape +) { + assertNotNull(outBase, "outBase must not be null"); + assertNotNull(outShape, "outShape must not be null"); + + tile_t tile = mapGetTile((worldpos_t){ x, y, layer }); + worldunit_t base = layer; + + if(!tileShapeIsWalkable(tile.shape) && movingHorizontally) { + const tile_t tileAbove = + mapGetTile((worldpos_t){ x, y, (worldunit_t)(layer + 1) }); + if(tileShapeIsWalkable(tileAbove.shape)) { + tile = tileAbove; + base = (worldunit_t)(layer + 1); + } else { + const tile_t tileBelow = + mapGetTile((worldpos_t){ x, y, (worldunit_t)(layer - 1) }); + if(tileShapeIsWalkable(tileBelow.shape)) { + tile = tileBelow; + base = (worldunit_t)(layer - 1); + } + } + } + + *outBase = base; + *outShape = tile.shape; + return tileShapeIsWalkable(tile.shape); +} + void physicsWorldResolveBodyOverlap( physicsbody_t *body, const uint8_t axis, diff --git a/src/dusk/rpg/physics/physicsworld.h b/src/dusk/rpg/physics/physicsworld.h index f0b348bd..c2b84cef 100644 --- a/src/dusk/rpg/physics/physicsworld.h +++ b/src/dusk/rpg/physics/physicsworld.h @@ -7,6 +7,8 @@ #pragma once #include "physicsbody.h" +#include "rpg/overworld/worldpos.h" +#include "rpg/overworld/tileshape.h" #define PHYSICS_WORLD_GRAVITY_DEFAULT 20.0f #define PHYSICS_WORLD_TERMINAL_VELOCITY_DEFAULT 40.0f @@ -46,12 +48,30 @@ void physicsWorldInit( * bodies (see physicsWorldResolveBodyOverlap) - so another body is * treated as solid too, not just the tile map. * + * Ramp tiles are walkable at a continuously-varying height (see + * tileShapeGetRampHeight) rather than a flat Z layer - walking across one + * lifts or drops the body smoothly to match the tile's sloped surface, + * snapping upward as needed since gravity alone only ever pulls down. + * * Known limitations, deliberately out of scope for this very basic pass: - * - No ramp/slope support - ramp tiles are treated as flat walkable - * ground at whatever Z layer they are queried at. - * - No wall/hole distinction - horizontal movement treats any - * non-walkable (or unloaded) column at the body's current Z layer as - * a solid wall, rather than an edge to fall from. + * - Ceiling/head-bump checks are still flat and ramp-unaware - only the + * walking surface accounts for slope, not the underside of a ramp + * above the body. + * - Descending a fast-dropping ramp relies on gravity to close the gap + * each step rather than an explicit "snap down" - imperceptible at + * normal walk/run speeds given the default gravity, but a real + * asymmetry with the "snap up" case. + * - A body whose footprint spans multiple tile columns (unused by any + * entity today - all default to a 1x1 footprint) queries each + * column's ramp height using its own clamped local coordinate + * independently, rather than blending a single surface across tiles. + * - Limited wall/hole distinction - horizontal movement checks the + * body's current Z layer plus one layer above and below (each column + * only ever stores a single tile at one Z layer, so this covers + * stepping onto an adjacent ascending or descending ramp/ledge + * whose own layer differs from the body's current one). A column + * with nothing walkable within one layer either way is still treated + * as a solid wall rather than a deeper edge to fall from. * - A single step can tunnel through an intervening solid Z layer if * velocity.z * dt exceeds one grid unit; terminalVelocity bounds this * but does not eliminate it for very small/thin floors. @@ -152,3 +172,30 @@ void physicsWorldResolveBodyOverlap( physicsbody_t * const *others, const uint32_t othersCount ); + +/** + * Finds the walkable tile for a single tile column (x, y) near the given + * layer: checks the layer itself first, and - only if movingHorizontally + * is true, so a stationary body never mistakes a genuine ceiling above + * it for ground - falls back to one layer above then one layer below. + * Each column only ever has one real tile, so this lets a column whose + * own tile sits adjacent to the current layer (e.g. finishing a climb + * onto, or starting a descent from, an adjacent ramp/ledge) be found. + * + * @param x Column X coordinate. + * @param y Column Y coordinate. + * @param layer The layer to check first. + * @param movingHorizontally Whether to also try one layer above/below. + * @param outBase Output, set to the layer the found tile actually sits + * at (layer, layer + 1, or layer - 1). + * @param outShape Output, set to the found tile's shape. + * @return true if a walkable tile was found. + */ +bool_t physicsWorldFindColumnTile( + const worldunit_t x, + const worldunit_t y, + const worldunit_t layer, + const bool_t movingHorizontally, + worldunit_t *outBase, + tileshape_t *outShape +); diff --git a/src/dusk/ui/debug/uifps.c b/src/dusk/ui/debug/uifps.c index 901e3b09..b75d6774 100644 --- a/src/dusk/ui/debug/uifps.c +++ b/src/dusk/ui/debug/uifps.c @@ -7,7 +7,6 @@ #include "uifps.h" #include "time/time.h" -#include "display/spritebatch/spritebatch.h" #include "display/text/text.h" #include "display/screen/screen.h" #include "engine/engine.h" @@ -56,7 +55,6 @@ errorret_t uiFPSDraw() { fpsText, textColor, &FONT_DEFAULT )); - errorChain(spriteBatchFlush()); int32_t versionWidth, versionHeight; textMeasure(ENGINE.version, &FONT_DEFAULT, &versionWidth, &versionHeight); @@ -68,5 +66,5 @@ errorret_t uiFPSDraw() { &FONT_DEFAULT )); - return spriteBatchFlush(); + errorOk(); } \ No newline at end of file diff --git a/src/dusk/ui/debug/uiplayerpos.c b/src/dusk/ui/debug/uiplayerpos.c index 2d757b07..a3b9f2d5 100644 --- a/src/dusk/ui/debug/uiplayerpos.c +++ b/src/dusk/ui/debug/uiplayerpos.c @@ -8,7 +8,6 @@ #include "uiplayerpos.h" #include "display/screen/screen.h" #include "display/text/text.h" -#include "display/spritebatch/spritebatch.h" #include "rpg/entity/entity.h" #include "rpg/entity/entitytype.h" #include "rpg/overworld/worldpos.h" @@ -60,5 +59,5 @@ errorret_t uiPlayerPosDraw() { (float_t)SCREEN.scanX, velocityY, velocityText, COLOR_GREEN, &FONT_DEFAULT )); - return spriteBatchFlush(); + errorOk(); } diff --git a/src/dusk/ui/frame/backpack/uibackpack.c b/src/dusk/ui/frame/backpack/uibackpack.c index d2cbddbf..16a8f0d1 100644 --- a/src/dusk/ui/frame/backpack/uibackpack.c +++ b/src/dusk/ui/frame/backpack/uibackpack.c @@ -10,7 +10,6 @@ #include "rpg/item/backpack.h" #include "util/memory.h" #include "util/string.h" -#include "display/spritebatch/spritebatch.h" #include "display/screen/screen.h" #include "display/text/text.h" #include "assert/assert.h" @@ -95,7 +94,6 @@ errorret_t uiBackpackDraw(void) { uiItemListDraw(&UI_BACKPACK.itemList, contentX, listY, contentWidth) ); - errorChain(spriteBatchFlush()); errorOk(); } diff --git a/src/dusk/ui/frame/game/uigamemenu.c b/src/dusk/ui/frame/game/uigamemenu.c index bfdb2203..d1a531ca 100644 --- a/src/dusk/ui/frame/game/uigamemenu.c +++ b/src/dusk/ui/frame/game/uigamemenu.c @@ -10,7 +10,6 @@ #include "ui/frame/settings/uisettings.h" #include "ui/frame/backpack/uibackpack.h" #include "util/memory.h" -#include "display/spritebatch/spritebatch.h" #include "display/screen/screen.h" #include "assert/assert.h" #include "locale/localemanager.h" @@ -85,7 +84,6 @@ errorret_t uiGameMenuDraw(void) { height - (UI_FRAME_START_Y * 2) )); - errorChain(spriteBatchFlush()); errorOk(); } diff --git a/src/dusk/ui/frame/settings/uisettings.c b/src/dusk/ui/frame/settings/uisettings.c index 23b496e5..03423be5 100644 --- a/src/dusk/ui/frame/settings/uisettings.c +++ b/src/dusk/ui/frame/settings/uisettings.c @@ -13,7 +13,6 @@ #include "ui/frame/uiframe.h" #include "ui/frame/uiconfirm.h" #include "util/memory.h" -#include "display/spritebatch/spritebatch.h" #include "display/text/text.h" #include "display/screen/screen.h" #include "assert/assert.h" @@ -174,7 +173,6 @@ errorret_t uiSettingsDraw(void) { )); } - errorChain(spriteBatchFlush()); errorOk(); } diff --git a/src/dusk/ui/frame/uiconfirm.c b/src/dusk/ui/frame/uiconfirm.c index 2fded7ce..58cfdb94 100644 --- a/src/dusk/ui/frame/uiconfirm.c +++ b/src/dusk/ui/frame/uiconfirm.c @@ -69,7 +69,6 @@ errorret_t uiConfirmDraw(void) { errorChain( spriteBatchBuffer(&backdropSprite, 1, &SHADER_UNLIT, backdropMaterial) ); - errorChain(spriteBatchFlush()); int32_t textW, textH; textMeasure(UI_CONFIRM.text, &FONT_DEFAULT, &textW, &textH); @@ -94,7 +93,6 @@ errorret_t uiConfirmDraw(void) { float_t buttonsY = contentY + rowHeight + UI_FRAME_PADDING_Y; errorChain(uiMenuDraw(&UI_CONFIRM.menu, contentX, buttonsY, contentWidth, rowHeight)); - errorChain(spriteBatchFlush()); errorOk(); } diff --git a/src/dusk/ui/overlay/uicrop.c b/src/dusk/ui/overlay/uicrop.c index 3e8d388b..ab9ea27d 100644 --- a/src/dusk/ui/overlay/uicrop.c +++ b/src/dusk/ui/overlay/uicrop.c @@ -85,5 +85,5 @@ errorret_t uiCropDraw(void) { } }; errorChain(spriteBatchBuffer(sprites, count, &SHADER_UNLIT, material)); - return spriteBatchFlush(); + errorOk(); } diff --git a/src/dusk/ui/overlay/uifullbox.c b/src/dusk/ui/overlay/uifullbox.c index 0fdb6e74..1ef2d7d0 100644 --- a/src/dusk/ui/overlay/uifullbox.c +++ b/src/dusk/ui/overlay/uifullbox.c @@ -75,7 +75,7 @@ errorret_t uiFullboxDraw(uifullbox_t *fullbox) { } }; errorChain(spriteBatchBuffer(&sprite, 1, &SHADER_UNLIT, material)); - return spriteBatchFlush(); + errorOk(); } void uiFullboxTransition( diff --git a/src/dusk/ui/overlay/uiloading.c b/src/dusk/ui/overlay/uiloading.c index 4dc86e3b..05b932a4 100644 --- a/src/dusk/ui/overlay/uiloading.c +++ b/src/dusk/ui/overlay/uiloading.c @@ -11,7 +11,6 @@ #include "time/time.h" #include "display/text/text.h" #include "display/screen/screen.h" -#include "display/spritebatch/spritebatch.h" #include "uifullbox.h" #define UI_LOADING_TEXT "loading" @@ -64,7 +63,7 @@ errorret_t uiLoadingDraw(void) { color.a = (uint8_t)(alpha * 255.0f); errorChain(textDraw(x, y, UI_LOADING_TEXT, color, &FONT_DEFAULT)); - return spriteBatchFlush(); + errorOk(); } static void uiLoadingTransition( diff --git a/src/dusk/ui/rpg/textbox/uitextbox.c b/src/dusk/ui/rpg/textbox/uitextbox.c index 47bdbdf6..272b7e89 100644 --- a/src/dusk/ui/rpg/textbox/uitextbox.c +++ b/src/dusk/ui/rpg/textbox/uitextbox.c @@ -170,7 +170,6 @@ errorret_t uiTextboxDraw( } errorChain(uiFrameDraw(x, y, width, height)); - errorChain(spriteBatchFlush()); if(box->lineCount == 0 || box->text[0] == '\0') errorOk(); @@ -210,7 +209,6 @@ errorret_t uiTextboxDraw( charsLeft -= visible; } - errorChain(spriteBatchFlush()); errorOk(); } diff --git a/src/dusk/ui/rpg/textbox/uitextboxmain.c b/src/dusk/ui/rpg/textbox/uitextboxmain.c index e65892ea..f81cce8a 100644 --- a/src/dusk/ui/rpg/textbox/uitextboxmain.c +++ b/src/dusk/ui/rpg/textbox/uitextboxmain.c @@ -79,7 +79,6 @@ errorret_t uiTextboxMainDraw(void) { &FONT_DEFAULT ); errorChain(spriteBatchBuffer(&caret, 1, &SHADER_UNLIT, material)); - errorChain(spriteBatchFlush()); errorOk(); } diff --git a/src/dusk/ui/rpg/uiemoji.c b/src/dusk/ui/rpg/uiemoji.c index 21a3cbf2..387e873d 100644 --- a/src/dusk/ui/rpg/uiemoji.c +++ b/src/dusk/ui/rpg/uiemoji.c @@ -110,7 +110,6 @@ errorret_t uiEmojiDraw(void) { } }; errorChain(spriteBatchBuffer(sprites, count, &SHADER_UNLIT, mat)); - errorChain(spriteBatchFlush()); errorOk(); } diff --git a/src/dusk/ui/transition/uitransitionfade.c b/src/dusk/ui/transition/uitransitionfade.c index 45e3168d..78567ce3 100644 --- a/src/dusk/ui/transition/uitransitionfade.c +++ b/src/dusk/ui/transition/uitransitionfade.c @@ -52,5 +52,5 @@ errorret_t uiTransitionFadeDraw(const uitransitiondata_t *data) { } }; errorChain(spriteBatchBuffer(&sprite, 1, &SHADER_UNLIT, material)); - return spriteBatchFlush(); + errorOk(); } diff --git a/src/dusk/ui/ui.c b/src/dusk/ui/ui.c index 566672fc..2d89d901 100644 --- a/src/dusk/ui/ui.c +++ b/src/dusk/ui/ui.c @@ -12,7 +12,6 @@ #include "display/screen/screen.h" #include "ui/uielement.h" #include "ui/focus/uifocus.h" -#include "log/log.h" ui_t UI; @@ -45,14 +44,10 @@ errorret_t uiRender(void) { const uielement_t *element = &UI_ELEMENTS[0]; while(!uiElementIsNull(element)) { errorChain(uiElementDraw(element)); - - if(SPRITEBATCH.spriteCount > 0) { - logDebug("Finished UI element but unflushed sprites remain.\n"); - } - element++; } + errorChain(spriteBatchFlush()); errorOk(); } diff --git a/src/dusk/ui/widget/uislider.c b/src/dusk/ui/widget/uislider.c index 95722b80..e08a041d 100644 --- a/src/dusk/ui/widget/uislider.c +++ b/src/dusk/ui/widget/uislider.c @@ -170,7 +170,6 @@ errorret_t uiSliderDraw( } }; errorChain(spriteBatchBuffer(&trackSprite, 1, &SHADER_UNLIT, trackMaterial)); - errorChain(spriteBatchFlush()); float_t fillWidth = UI_SLIDER_TRACK_WIDTH * uiSliderGetRatio(slider); if(fillWidth > 0.0f) { @@ -187,7 +186,6 @@ errorret_t uiSliderDraw( } }; errorChain(spriteBatchBuffer(&fillSprite, 1, &SHADER_UNLIT, fillMaterial)); - errorChain(spriteBatchFlush()); } int32_t stepCount = uiSliderGetStepCount(slider); @@ -218,7 +216,6 @@ errorret_t uiSliderDraw( errorChain( spriteBatchBuffer(&markerSprite, 1, &SHADER_UNLIT, markerMaterial) ); - errorChain(spriteBatchFlush()); } } diff --git a/src/dusk/ui/widget/uitab.c b/src/dusk/ui/widget/uitab.c index 12073ef2..3ba0fdab 100644 --- a/src/dusk/ui/widget/uitab.c +++ b/src/dusk/ui/widget/uitab.c @@ -54,7 +54,6 @@ errorret_t uiTabDraw( } }; errorChain(spriteBatchBuffer(&sprite, 1, &SHADER_UNLIT, material)); - errorChain(spriteBatchFlush()); errorChain(textDraw(x, y, tab->label, COLOR_WHITE, &FONT_DEFAULT)); diff --git a/src/duskgl/display/mesh/meshgl.c b/src/duskgl/display/mesh/meshgl.c index fa6b7ed3..a454f8f9 100644 --- a/src/duskgl/display/mesh/meshgl.c +++ b/src/duskgl/display/mesh/meshgl.c @@ -93,13 +93,11 @@ errorret_t meshFlushGL( #else glBindBuffer(GL_ARRAY_BUFFER, mesh->vboId); errorChain(errorGLCheck()); - glBufferData( + glBufferSubData( GL_ARRAY_BUFFER, - mesh->vertexCount * sizeof(meshvertex_t), - mesh->vertices, - // vertCount * sizeof(meshvertex_t), - // &mesh->vertices[vertOffset], - GL_DYNAMIC_DRAW + vertOffset * sizeof(meshvertex_t), + vertCount * sizeof(meshvertex_t), + &mesh->vertices[vertOffset] ); errorChain(errorGLCheck()); #endif diff --git a/test/rpg/overworld/CMakeLists.txt b/test/rpg/overworld/CMakeLists.txt index 7f8ecc94..5449d2e4 100644 --- a/test/rpg/overworld/CMakeLists.txt +++ b/test/rpg/overworld/CMakeLists.txt @@ -7,5 +7,6 @@ include(dusktest) # Tests dusktest(test_maparea.c) +dusktest(test_tileshape.c) # Subdirs \ No newline at end of file diff --git a/test/rpg/overworld/test_tileshape.c b/test/rpg/overworld/test_tileshape.c new file mode 100644 index 00000000..8eb92967 --- /dev/null +++ b/test/rpg/overworld/test_tileshape.c @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "util/memory.h" +#include "rpg/overworld/tileshape.h" + +static void test_tileShapeGetRampHeightGroundIsAlwaysFlat(void **state) { + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_GROUND, 0.0f, 0.0f), 0.0f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_GROUND, 1.0f, 1.0f), 0.0f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_GROUND, 0.5f, 0.5f), 0.0f, 0.0001f + ); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_tileShapeGetRampHeightCardinalRamps(void **state) { + // RAMP_NORTH: rises going north (+y) - height equals localY. + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTH, 0.5f, 0.0f), 0.0f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTH, 0.5f, 1.0f), 1.0f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTH, 0.0f, 0.5f), 0.5f, 0.0001f + ); + + // RAMP_SOUTH: rises going south (-y) - height equals 1 - localY. + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_SOUTH, 0.5f, 0.0f), 1.0f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_SOUTH, 0.5f, 1.0f), 0.0f, 0.0001f + ); + + // RAMP_EAST: rises going east (+x) - height equals localX. + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_EAST, 0.0f, 0.5f), 0.0f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_EAST, 1.0f, 0.5f), 1.0f, 0.0001f + ); + + // RAMP_WEST: rises going west (-x) - height equals 1 - localX. + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_WEST, 0.0f, 0.5f), 1.0f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_WEST, 1.0f, 0.5f), 0.0f, 0.0001f + ); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_tileShapeGetRampHeightOuterCornerRamps(void **state) { + // RAMP_NORTHEAST: only the NE corner raised - a hip shape, height + // equal to min(localX, localY) since the mesh is split along the + // SW-NE diagonal into two flat triangles. + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST, 0.0f, 0.0f), + 0.0f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST, 1.0f, 1.0f), + 1.0f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST, 0.3f, 0.7f), + 0.3f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST, 0.7f, 0.3f), + 0.3f, 0.0001f + ); + + // RAMP_SOUTHWEST: only the SW corner raised - height equal to + // min(1 - localX, 1 - localY). + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_SOUTHWEST, 0.0f, 0.0f), + 1.0f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_SOUTHWEST, 1.0f, 1.0f), + 0.0f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_SOUTHWEST, 1.0f, 0.0f), + 0.0f, 0.0001f + ); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_tileShapeGetRampHeightInnerCornerRamp(void **state) { + // RAMP_NORTHEAST_INNER: only the opposite (SW) corner lowered, rest of + // the tile raised - height equal to max(localX, localY). + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST_INNER, 0.0f, 0.0f), + 0.0f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST_INNER, 1.0f, 1.0f), + 1.0f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST_INNER, 0.3f, 0.7f), + 0.7f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST_INNER, 0.7f, 0.3f), + 0.7f, 0.0001f + ); + assert_float_equal( + tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST_INNER, 1.0f, 0.0f), + 1.0f, 0.0001f + ); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +int main(void) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_tileShapeGetRampHeightGroundIsAlwaysFlat), + cmocka_unit_test(test_tileShapeGetRampHeightCardinalRamps), + cmocka_unit_test(test_tileShapeGetRampHeightOuterCornerRamps), + cmocka_unit_test(test_tileShapeGetRampHeightInnerCornerRamp), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/rpg/physics/test_physicsworld.c b/test/rpg/physics/test_physicsworld.c index aca90dfb..33134c95 100644 --- a/test/rpg/physics/test_physicsworld.c +++ b/test/rpg/physics/test_physicsworld.c @@ -224,16 +224,91 @@ static void test_physicsWorldStepMultiColumnFootprint(void **state) { assert_int_equal(memoryGetAllocatedCount(), 0); } -static void test_physicsWorldStepRampTreatedAsFlatGround(void **state) { +static void test_physicsWorldStepClimbsRampGoingUp(void **state) { testMapReset(); testMapSetTile(0, 0, 0, TILE_SHAPE_GROUND); testMapSetTile(1, 0, 0, TILE_SHAPE_RAMP_EAST); + for(worldunit_t x = 2; x < 6; x++) { + testMapSetTile(x, 0, 1, TILE_SHAPE_GROUND); + } + + physicsworld_t world; + physicsWorldInit(&world, 20.0f, 40.0f); + + physicsbody_t body; + const vec3 position = { 0.0f, 0.0f, 0.0f }; + const vec3 extents = { 1.0f, 1.0f, 1.0f }; + physicsBodyInit(&body, position, extents); + body.velocity[0] = 1.0f; + + for(uint32_t i = 0; i < 400; i++) { + physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); + + // Ground height is driven by the body's center (not its corner + // position), so a 1-wide body barely touching a taller neighbouring + // column isn't yanked onto that column's full height - see + // physicsWorldResolveAxisZ. While the center is over the ramp tile, + // height should track how far across it smoothly, not snap flat. + const float_t centerX = body.position[0] + 0.5f; + if(centerX >= 1.0f && centerX < 2.0f) { + const float_t expected = centerX - 1.0f; + assert_float_equal(body.position[2], expected, 0.005f); + } + } + + // Fully across, resting on the elevated ground one layer up. + assert_float_equal(body.position[0], 5.0f, 0.001f); + assert_float_equal(body.position[2], 1.0f, 0.005f); + assert_true(body.grounded); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_physicsWorldStepDescendsRampGoingDown(void **state) { + testMapReset(); + testMapSetTile(0, 0, 1, TILE_SHAPE_GROUND); + testMapSetTile(1, 0, 0, TILE_SHAPE_RAMP_WEST); for(worldunit_t x = 2; x < 6; x++) { testMapSetTile(x, 0, 0, TILE_SHAPE_GROUND); } physicsworld_t world; - physicsWorldInit(&world, 0.0f, 40.0f); + physicsWorldInit(&world, 20.0f, 40.0f); + + physicsbody_t body; + const vec3 position = { 0.0f, 0.0f, 1.0f }; + const vec3 extents = { 1.0f, 1.0f, 1.0f }; + physicsBodyInit(&body, position, extents); + body.velocity[0] = 1.0f; + + for(uint32_t i = 0; i < 400; i++) { + physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); + + // Never falls through as a hole while crossing the ramp - descent + // may lag slightly behind the ideal slope (gravity closes the gap + // each step, see physicsWorldStep's documented limitation) but must + // stay close to it, never dropping toward the void below. + assert_true(body.position[2] >= -0.2f); + } + + // Fully across, resting on the lower ground. + assert_float_equal(body.position[0], 5.0f, 0.001f); + assert_float_equal(body.position[2], 0.0f, 0.005f); + assert_true(body.grounded); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_physicsWorldStepFlatGroundUnaffectedByRampLogic( + void **state +) { + testMapReset(); + for(worldunit_t x = 0; x < 6; x++) { + testMapSetTile(x, 0, 0, TILE_SHAPE_GROUND); + } + + physicsworld_t world; + physicsWorldInit(&world, 20.0f, 40.0f); physicsbody_t body; const vec3 position = { 0.0f, 0.0f, 0.0f }; @@ -246,7 +321,6 @@ static void test_physicsWorldStepRampTreatedAsFlatGround(void **state) { assert_float_equal(body.position[2], 0.0f, 0.0001f); } - assert_float_equal(body.velocity[0], 5.0f, 0.0001f); assert_true(body.position[0] > 1.5f); assert_int_equal(memoryGetAllocatedCount(), 0); @@ -346,7 +420,9 @@ int main(void) { cmocka_unit_test(test_physicsWorldStepTerminalVelocityClamp), cmocka_unit_test(test_physicsWorldStepBlockedByCeiling), cmocka_unit_test(test_physicsWorldStepMultiColumnFootprint), - cmocka_unit_test(test_physicsWorldStepRampTreatedAsFlatGround), + cmocka_unit_test(test_physicsWorldStepClimbsRampGoingUp), + cmocka_unit_test(test_physicsWorldStepDescendsRampGoingDown), + cmocka_unit_test(test_physicsWorldStepFlatGroundUnaffectedByRampLogic), cmocka_unit_test(test_physicsWorldStepBlockedByOtherBody), cmocka_unit_test(test_physicsWorldStepBlockedByNearestOfMultipleBodies), cmocka_unit_test(test_physicsWorldResolveBodyOverlapIgnoresSelfAndNull),