diff --git a/src/dusk/rpg/entity/CMakeLists.txt b/src/dusk/rpg/entity/CMakeLists.txt index da55a228..5af2ce99 100644 --- a/src/dusk/rpg/entity/CMakeLists.txt +++ b/src/dusk/rpg/entity/CMakeLists.txt @@ -12,7 +12,6 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} player.c ) -add_subdirectory(anim) add_subdirectory(interact) add_subdirectory(npc) add_subdirectory(item) diff --git a/src/dusk/rpg/entity/anim/CMakeLists.txt b/src/dusk/rpg/entity/anim/CMakeLists.txt deleted file mode 100644 index 54c1015e..00000000 --- a/src/dusk/rpg/entity/anim/CMakeLists.txt +++ /dev/null @@ -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 - entityanim.c - entityanimidle.c - entityanimturn.c - entityanimwalk.c - entityanimrun.c -) diff --git a/src/dusk/rpg/entity/anim/entityanim.c b/src/dusk/rpg/entity/anim/entityanim.c deleted file mode 100644 index fa98628f..00000000 --- a/src/dusk/rpg/entity/anim/entityanim.c +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright (c) 2026 Dominic Masters - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -#include "rpg/entity/entity.h" -#include "rpg/overworld/map.h" -#include "rpg/overworld/tile.h" -#include "time/time.h" -#include "entityanimwalk.h" - -const entityanimcallback_t ENTITY_ANIM_CALLBACKS[ENTITY_ANIM_COUNT] = { - [ENTITY_ANIM_IDLE] = { entityAnimIdleUpdate }, - [ENTITY_ANIM_TURN] = { entityAnimTurnUpdate }, - [ENTITY_ANIM_WALK] = { entityAnimWalkUpdate }, - [ENTITY_ANIM_RUN] = { entityAnimRunUpdate }, -}; - -float_t entityAnimTileZOffset(const worldpos_t pos) { - return tileShapeIsRamp(mapGetTile(pos).shape) ? 0.5f : 0.0f; -} - -void entityAnimUpdate(entity_t *entity) { - if(entity->animation != ENTITY_ANIM_IDLE) { - entity->animTime -= TIME.delta; - if(entity->animTime <= 0) { - if( - entity->animation == ENTITY_ANIM_WALK || - entity->animation == ENTITY_ANIM_RUN - ) { - entity->walkEndCooldown = ENTITY_ANIM_WALK_TURN_COOLDOWN; - } - entity->animation = ENTITY_ANIM_IDLE; - entity->animTime = 0; - } - } - if(entity->walkEndCooldown > 0) { - entity->walkEndCooldown -= TIME.delta; - if(entity->walkEndCooldown < 0) entity->walkEndCooldown = 0; - } - ENTITY_ANIM_CALLBACKS[entity->animation].update(entity); -} diff --git a/src/dusk/rpg/entity/anim/entityanim.h b/src/dusk/rpg/entity/anim/entityanim.h deleted file mode 100644 index ff4cde20..00000000 --- a/src/dusk/rpg/entity/anim/entityanim.h +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Copyright (c) 2026 Dominic Masters - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -#pragma once -#include "dusk.h" -#include "entityanimidle.h" -#include "entityanimturn.h" -#include "entityanimwalk.h" -#include "entityanimrun.h" - -typedef struct entity_s entity_t; - -typedef enum { - ENTITY_ANIM_IDLE, - ENTITY_ANIM_TURN, - ENTITY_ANIM_WALK, - ENTITY_ANIM_RUN, - ENTITY_ANIM_COUNT -} entityanim_t; - -typedef struct { - /** Updates the render position for this animation state. */ - void (*update)(entity_t *entity); -} entityanimcallback_t; - -extern const entityanimcallback_t ENTITY_ANIM_CALLBACKS[ENTITY_ANIM_COUNT]; - -/** - * Updates the entity animation timer and render position. - * - * @param entity Pointer to the entity to update. - */ -void entityAnimUpdate(entity_t *entity); - -/** - * Returns 0.5 if the tile at pos is a ramp, 0.0 otherwise. - * Used to lift entity render position to mid-ramp height. - * - * @param pos World position to sample. - * @returns float_t The Z offset to apply. - */ -float_t entityAnimTileZOffset(const worldpos_t pos); diff --git a/src/dusk/rpg/entity/anim/entityanimidle.c b/src/dusk/rpg/entity/anim/entityanimidle.c deleted file mode 100644 index fb9f5d4a..00000000 --- a/src/dusk/rpg/entity/anim/entityanimidle.c +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) 2026 Dominic Masters - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -#include "rpg/entity/entity.h" -#include "entityanim.h" - -void entityAnimIdleUpdate(entity_t *entity) { - entity->renderPosition[0] = (float_t)entity->position.x; - entity->renderPosition[1] = (float_t)entity->position.y; - entity->renderPosition[2] = ( - (float_t)entity->position.z + entityAnimTileZOffset(entity->position) - ) * WORLD_LAYER_HEIGHT; -} diff --git a/src/dusk/rpg/entity/anim/entityanimidle.h b/src/dusk/rpg/entity/anim/entityanimidle.h deleted file mode 100644 index b7bd9a53..00000000 --- a/src/dusk/rpg/entity/anim/entityanimidle.h +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) 2026 Dominic Masters - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -#pragma once -#include "dusk.h" - -typedef struct entity_s entity_t; - -/** - * Updates render position for the idle animation state. - * - * @param entity Pointer to the entity to update. - */ -void entityAnimIdleUpdate(entity_t *entity); diff --git a/src/dusk/rpg/entity/anim/entityanimrun.c b/src/dusk/rpg/entity/anim/entityanimrun.c deleted file mode 100644 index 27a32c2c..00000000 --- a/src/dusk/rpg/entity/anim/entityanimrun.c +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright (c) 2026 Dominic Masters - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -#include "rpg/entity/entity.h" -#include "entityanim.h" - -void entityAnimRunUpdate(entity_t *entity) { - float_t t = 1.0f - (entity->animTime / ENTITY_ANIM_RUN_DURATION); - float_t zFrom = (float_t)entity->lastPosition.z - + entityAnimTileZOffset(entity->lastPosition); - float_t zTo = (float_t)entity->position.z - + entityAnimTileZOffset(entity->position); - entity->renderPosition[0] = (float_t)entity->lastPosition.x + t * ( - (float_t)entity->position.x - (float_t)entity->lastPosition.x - ); - entity->renderPosition[1] = (float_t)entity->lastPosition.y + t * ( - (float_t)entity->position.y - (float_t)entity->lastPosition.y - ); - entity->renderPosition[2] = (zFrom + t * (zTo - zFrom)) * WORLD_LAYER_HEIGHT; -} diff --git a/src/dusk/rpg/entity/anim/entityanimrun.h b/src/dusk/rpg/entity/anim/entityanimrun.h deleted file mode 100644 index ecace434..00000000 --- a/src/dusk/rpg/entity/anim/entityanimrun.h +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) 2026 Dominic Masters - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -#pragma once -#include "dusk.h" -#include "time/time.h" - -typedef struct entity_s entity_t; - -#define ENTITY_ANIM_RUN_DURATION TIME_TICKS_TO_TIME(6) - -/** - * Updates render position for the run animation state. - * - * @param entity Pointer to the entity to update. - */ -void entityAnimRunUpdate(entity_t *entity); diff --git a/src/dusk/rpg/entity/anim/entityanimturn.c b/src/dusk/rpg/entity/anim/entityanimturn.c deleted file mode 100644 index d31ad814..00000000 --- a/src/dusk/rpg/entity/anim/entityanimturn.c +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) 2026 Dominic Masters - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -#include "rpg/entity/entity.h" -#include "entityanim.h" - -void entityAnimTurnUpdate(entity_t *entity) { - entity->renderPosition[0] = (float_t)entity->position.x; - entity->renderPosition[1] = (float_t)entity->position.y; - entity->renderPosition[2] = ( - (float_t)entity->position.z + entityAnimTileZOffset(entity->position) - ) * WORLD_LAYER_HEIGHT; -} diff --git a/src/dusk/rpg/entity/anim/entityanimturn.h b/src/dusk/rpg/entity/anim/entityanimturn.h deleted file mode 100644 index 33368d52..00000000 --- a/src/dusk/rpg/entity/anim/entityanimturn.h +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) 2026 Dominic Masters - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -#pragma once -#include "dusk.h" -#include "time/time.h" - -typedef struct entity_s entity_t; - -#define ENTITY_ANIM_TURN_DURATION TIME_TICKS_TO_TIME(4) - -/** - * Updates render position for the turn animation state. - * - * @param entity Pointer to the entity to update. - */ -void entityAnimTurnUpdate(entity_t *entity); diff --git a/src/dusk/rpg/entity/anim/entityanimwalk.c b/src/dusk/rpg/entity/anim/entityanimwalk.c deleted file mode 100644 index e8aa2833..00000000 --- a/src/dusk/rpg/entity/anim/entityanimwalk.c +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright (c) 2026 Dominic Masters - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -#include "rpg/entity/entity.h" -#include "entityanim.h" - -void entityAnimWalkUpdate(entity_t *entity) { - float_t t = 1.0f - (entity->animTime / ENTITY_ANIM_WALK_DURATION); - float_t zFrom = (float_t)entity->lastPosition.z - + entityAnimTileZOffset(entity->lastPosition); - float_t zTo = (float_t)entity->position.z - + entityAnimTileZOffset(entity->position); - entity->renderPosition[0] = (float_t)entity->lastPosition.x + t * ( - (float_t)entity->position.x - (float_t)entity->lastPosition.x - ); - entity->renderPosition[1] = (float_t)entity->lastPosition.y + t * ( - (float_t)entity->position.y - (float_t)entity->lastPosition.y - ); - entity->renderPosition[2] = (zFrom + t * (zTo - zFrom)) * WORLD_LAYER_HEIGHT; -} diff --git a/src/dusk/rpg/entity/anim/entityanimwalk.h b/src/dusk/rpg/entity/anim/entityanimwalk.h deleted file mode 100644 index 5ab12585..00000000 --- a/src/dusk/rpg/entity/anim/entityanimwalk.h +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) 2026 Dominic Masters - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -#pragma once -#include "dusk.h" -#include "time/time.h" - -typedef struct entity_s entity_t; - -#define ENTITY_ANIM_WALK_DURATION TIME_TICKS_TO_TIME(12) -#define ENTITY_ANIM_WALK_TURN_COOLDOWN TIME_TICKS_TO_TIME(4) - -/** - * Updates render position for the walk animation state. - * - * @param entity Pointer to the entity to update. - */ -void entityAnimWalkUpdate(entity_t *entity); diff --git a/src/dusk/rpg/entity/entity.c b/src/dusk/rpg/entity/entity.c index 4b2b4a65..7f21bab8 100644 --- a/src/dusk/rpg/entity/entity.c +++ b/src/dusk/rpg/entity/entity.c @@ -1,6 +1,6 @@ /** * Copyright (c) 2025 Dominic Masters - * + * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ @@ -8,14 +8,14 @@ #include "entity.h" #include "assert/assert.h" #include "util/memory.h" -#include "time/time.h" #include "util/math.h" +#include "time/time.h" #include "rpg/overworld/map.h" #include "rpg/overworld/maparea.h" #include "rpg/overworld/chunk.h" -#include "rpg/overworld/tile.h" entity_t ENTITIES[ENTITY_COUNT]; +physicsworld_t ENTITY_PHYSICS_WORLD; void entityInit(entity_t *entity, const entitytype_t type) { assertNotNull(entity, "Entity pointer cannot be NULL"); @@ -32,6 +32,9 @@ void entityInit(entity_t *entity, const entitytype_t type) { entity->type = type; entity->chunkIndex = 0xFF; + const vec3 extents = ENTITY_PHYSICS_EXTENTS_DEFAULT; + physicsBodyInit(&entity->body, (vec3){ 0.0f, 0.0f, 0.0f }, extents); + if(ENTITY_CALLBACKS[type].init != NULL) ENTITY_CALLBACKS[type].init(entity); } @@ -40,212 +43,115 @@ void entityUpdate(entity_t *entity) { assertTrue(entity->type < ENTITY_TYPE_COUNT, "Invalid entity type"); assertTrue(entity->type != ENTITY_TYPE_NULL, "Cannot have NULL entity type"); - // What state is the entity in? - entityAnimUpdate(entity); - - // Movement code. if(ENTITY_CALLBACKS[entity->type].movement != NULL) { ENTITY_CALLBACKS[entity->type].movement(entity); } -} -bool_t entityCanTurn(entity_t *entity) { - return entity->animation == ENTITY_ANIM_IDLE && - entity->walkEndCooldown <= 0; -} + physicsbody_t *others[ENTITY_COUNT]; + uint32_t othersCount = 0; + for(uint8_t i = 0; i < ENTITY_COUNT; i++) { + if(ENTITIES[i].type == ENTITY_TYPE_NULL) continue; + if(&ENTITIES[i] == entity) continue; + others[othersCount++] = &ENTITIES[i].body; + } -bool_t entityCanWalk(entity_t *entity) { - return entity->animation == ENTITY_ANIM_IDLE; -} - -bool_t entityCanRun(entity_t *entity) { - return entity->animation == ENTITY_ANIM_IDLE; + physicsWorldStep( + &ENTITY_PHYSICS_WORLD, &entity->body, TIME.delta, others, othersCount + ); + entitySyncFromPhysics(entity); } bool_t entityCanUnload(entity_t *entity) { return entity->globalId < ENTITY_GLOBAL_ID_START; } +void entityMove( + entity_t *entity, const vec2 direction, const bool_t running +) { + assertNotNull(entity, "Entity pointer cannot be NULL"); + + const float_t magSq = + direction[0] * direction[0] + direction[1] * direction[1]; + if(magSq <= ENTITY_MOVE_DEADZONE * ENTITY_MOVE_DEADZONE) { + entity->body.velocity[0] = 0.0f; + entity->body.velocity[1] = 0.0f; + entity->animation = ENTITY_ANIM_IDLE; + return; + } + + const float_t mag = sqrtf(magSq); + const float_t clampedMag = mathMin(mag, 1.0f); + const float_t speed = running ? ENTITY_MOVE_RUN_SPEED : ENTITY_MOVE_WALK_SPEED; + + entity->body.velocity[0] = (direction[0] / mag) * clampedMag * speed; + entity->body.velocity[1] = (direction[1] / mag) * clampedMag * speed; + entity->direction = entityDirFromVec2(direction); + entity->animation = running ? ENTITY_ANIM_RUN : ENTITY_ANIM_WALK; +} + +void entityStop(entity_t *entity) { + const vec2 zero = { 0.0f, 0.0f }; + entityMove(entity, zero, false); +} + void entityTurn(entity_t *entity, const entitydir_t direction) { - if(!entityCanTurn(entity)) return; + assertNotNull(entity, "Entity pointer cannot be NULL"); entity->direction = direction; - entity->animation = ENTITY_ANIM_TURN; - entity->animTime = ENTITY_ANIM_TURN_DURATION; } void entityWalk(entity_t *entity, const entitydir_t direction) { - if(!entityCanWalk(entity)) return; - // TODO: Animation, delay, etc. - entity->direction = direction; - - // Where are we moving? - worldpos_t newPos = entity->position; - worldunits_t relX, relY; - { - entityDirGetRelative(direction, &relX, &relY); - newPos.x += relX; - newPos.y += relY; - } - - // Get tile under foot - tile_t tileCurrent = mapGetTile(entity->position); - tile_t tileNew = mapGetTile(newPos); - bool_t fall = false; - bool_t raise = false; - - // Are we walking up a ramp? - if( - tileShapeIsRamp(tileCurrent.shape) && - ( - // Can only walk UP the direction the ramp faces. - (direction+TILE_SHAPE_RAMP_NORTH) == tileCurrent.shape || - // If diagonal ramp, can go up one of two ways only. Inner ramps - // share the same allowed directions as their outer counterparts. - ( - ( - ( - tileCurrent.shape == TILE_SHAPE_RAMP_SOUTHEAST || - tileCurrent.shape == TILE_SHAPE_RAMP_SOUTHEAST_INNER - ) && - (direction == ENTITY_DIR_SOUTH || direction == ENTITY_DIR_EAST) - ) || - ( - ( - tileCurrent.shape == TILE_SHAPE_RAMP_SOUTHWEST || - tileCurrent.shape == TILE_SHAPE_RAMP_SOUTHWEST_INNER - ) && - (direction == ENTITY_DIR_SOUTH || direction == ENTITY_DIR_WEST) - ) || - ( - ( - tileCurrent.shape == TILE_SHAPE_RAMP_NORTHEAST || - tileCurrent.shape == TILE_SHAPE_RAMP_NORTHEAST_INNER - ) && - (direction == ENTITY_DIR_NORTH || direction == ENTITY_DIR_EAST) - ) || - ( - ( - tileCurrent.shape == TILE_SHAPE_RAMP_NORTHWEST || - tileCurrent.shape == TILE_SHAPE_RAMP_NORTHWEST_INNER - ) && - (direction == ENTITY_DIR_NORTH || direction == ENTITY_DIR_WEST) - ) - ) - // Must be able to walk up. - ) - ) { - tile_t tileNewSaved = tileNew; - tileNew = TILE_NULL; - worldpos_t abovePos = newPos; - abovePos.z += 1; - tile_t tileAbove = mapGetTile(abovePos); - - if( - tileAbove.shape != TILE_SHAPE_NULL && - tileShapeIsWalkable(tileAbove.shape) - ) { - raise = true; - } else { - tileNew = tileNewSaved; - } - } else if(tileNew.shape == TILE_SHAPE_NULL && newPos.z > 0) { - // Falling down? - worldpos_t belowPos = newPos; - belowPos.z -= 1; - tile_t tileBelow = mapGetTile(belowPos); - if( - tileBelow.shape != TILE_SHAPE_NULL && - tileShapeIsRamp(tileBelow.shape) && - ( - // This handles regular cardinal ramps - ( - entityDirGetOpposite(direction)+TILE_SHAPE_RAMP_NORTH - ) == tileBelow.shape || - // This handles diagonal ramps. Inner ramps share the same - // allowed directions as their outer counterparts. - ( - ( - ( - tileBelow.shape == TILE_SHAPE_RAMP_SOUTHEAST || - tileBelow.shape == TILE_SHAPE_RAMP_SOUTHEAST_INNER - ) && - (direction == ENTITY_DIR_NORTH || direction == ENTITY_DIR_WEST) - ) || - ( - ( - tileBelow.shape == TILE_SHAPE_RAMP_SOUTHWEST || - tileBelow.shape == TILE_SHAPE_RAMP_SOUTHWEST_INNER - ) && - (direction == ENTITY_DIR_NORTH || direction == ENTITY_DIR_EAST) - ) || - ( - ( - tileBelow.shape == TILE_SHAPE_RAMP_NORTHEAST || - tileBelow.shape == TILE_SHAPE_RAMP_NORTHEAST_INNER - ) && - (direction == ENTITY_DIR_SOUTH || direction == ENTITY_DIR_WEST) - ) || - ( - ( - tileBelow.shape == TILE_SHAPE_RAMP_NORTHWEST || - tileBelow.shape == TILE_SHAPE_RAMP_NORTHWEST_INNER - ) && - (direction == ENTITY_DIR_SOUTH || direction == ENTITY_DIR_EAST) - ) - ) - ) - ) { - // We will fall to this tile. - fall = true; - } - } - - // Can we walk here? - if(!raise && !fall && !tileShapeIsWalkable(tileNew.shape)) return;// Blocked - - // Raise/fall must be applied before checking for blocking entities, - // otherwise the check compares against the wrong z-level. - if(raise) { - newPos.z += 1; - } else if(fall) { - newPos.z -= 1; - } - - // Entity in way? - entity_t *other = ENTITIES; - do { - if(other == entity) continue; - if(other->type == ENTITY_TYPE_NULL) continue; - if(!worldPosIsEqual(other->position, newPos)) continue; - return;// Blocked - } while(++other, other < &ENTITIES[ENTITY_COUNT]); - - entity->lastPosition = entity->position; - entity->position = newPos; - entity->animation = ENTITY_ANIM_WALK; - entity->animTime = ENTITY_ANIM_WALK_DURATION;// TODO: Running vs walking - entityUpdateChunk(entity); - mapAreaCheckEntity(entity); + vec2 dirVec; + entityDirToVec2(direction, dirVec); + entityMove(entity, dirVec, false); } void entityRun(entity_t *entity, const entitydir_t direction) { - if(!entityCanRun(entity)) return; - entityWalk(entity, direction); - if(entity->animation == ENTITY_ANIM_WALK) { - entity->animation = ENTITY_ANIM_RUN; - entity->animTime = ENTITY_ANIM_RUN_DURATION; - } + vec2 dirVec; + entityDirToVec2(direction, dirVec); + entityMove(entity, dirVec, true); } -entity_t * entityGetAt(const worldpos_t position) { +entity_t * entityGetFacing(entity_t *entity, const float_t range) { + assertNotNull(entity, "Entity pointer cannot be NULL"); + + vec2 dir; + entityDirToVec2(entity->direction, dir); + + vec3 min, max; + physicsBodyGetBounds(&entity->body, min, max); + + const vec3 probeMin = { + min[0] + dir[0] * range, min[1] + dir[1] * range, min[2] + }; + const vec3 probeMax = { + max[0] + dir[0] * range, max[1] + dir[1] * range, max[2] + }; + + entity_t *best = NULL; + float_t bestDistSq = 0.0f; + entity_t *ent = ENTITIES; do { if(ent->type == ENTITY_TYPE_NULL) continue; - if(!worldPosIsEqual(ent->position, position)) continue; - return ent; + if(ent == entity) continue; + + vec3 oMin, oMax; + physicsBodyGetBounds(&ent->body, oMin, oMax); + + if(probeMin[0] >= oMax[0] || probeMax[0] <= oMin[0]) continue; + if(probeMin[1] >= oMax[1] || probeMax[1] <= oMin[1]) continue; + if(probeMin[2] >= oMax[2] || probeMax[2] <= oMin[2]) continue; + + const float_t dx = ent->body.position[0] - entity->body.position[0]; + const float_t dy = ent->body.position[1] - entity->body.position[1]; + const float_t distSq = dx * dx + dy * dy; + + if(best != NULL && distSq >= bestDistSq) continue; + best = ent; + bestDistSq = distSq; } while(++ent, ent < &ENTITIES[ENTITY_COUNT]); - return NULL; + return best; } entity_t * entityGetByGlobalId(const entityglobalid_t globalId) { @@ -270,12 +176,14 @@ uint8_t entityGetAvailable() { void entityPositionSet(entity_t *entity, const worldpos_t pos) { assertNotNull(entity, "Entity pointer cannot be NULL"); - entity->lastPosition = pos; - entity->position = pos; + + const vec3 floatPos = { + (float_t)pos.x, (float_t)pos.y, (float_t)pos.z + }; + const vec3 extents = ENTITY_PHYSICS_EXTENTS_DEFAULT; + physicsBodyInit(&entity->body, floatPos, extents); entity->animation = ENTITY_ANIM_IDLE; - entity->animTime = 0; - entity->walkEndCooldown = 0; - entityUpdateChunk(entity); + entitySyncFromPhysics(entity); } void entitySetChunk(entity_t *entity, const uint8_t chunkIndex) { @@ -297,11 +205,14 @@ void entitySetChunk(entity_t *entity, const uint8_t chunkIndex) { if(chunkIndex != 0xFF) { chunk_t *next = mapGetChunk(chunkIndex); if(next != NULL) { + bool_t inserted = false; for(uint8_t i = 0; i < CHUNK_ENTITY_COUNT_MAX; i++) { if(next->entities[i] != 0xFF) continue; next->entities[i] = entity->id; + inserted = true; break; } + assertTrue(inserted, "Chunk entity slot overflow"); } } } @@ -312,5 +223,22 @@ void entityUpdateChunk(entity_t *entity) { chunkpos_t cp; worldPosToChunkPos(&entity->position, &cp); chunkindex_t ci = mapGetChunkIndexAt(cp); - if(ci != -1) entitySetChunk(entity, (uint8_t)ci); -} \ No newline at end of file + if(ci == -1 || ci == entity->chunkIndex) return; + entitySetChunk(entity, (uint8_t)ci); +} + +void entitySyncFromPhysics(entity_t *entity) { + assertNotNull(entity, "Entity pointer cannot be NULL"); + + entity->position = (worldpos_t){ + (worldunit_t)floorf(entity->body.position[0]), + (worldunit_t)floorf(entity->body.position[1]), + (worldunit_t)floorf(entity->body.position[2]) + }; + + glm_vec3_copy(entity->body.position, entity->renderPosition); + entity->renderPosition[2] *= WORLD_LAYER_HEIGHT; + + entityUpdateChunk(entity); + mapAreaCheckEntity(entity); +} diff --git a/src/dusk/rpg/entity/entity.h b/src/dusk/rpg/entity/entity.h index e184b3b3..af24adde 100644 --- a/src/dusk/rpg/entity/entity.h +++ b/src/dusk/rpg/entity/entity.h @@ -7,10 +7,11 @@ #pragma once #include "entitydir.h" -#include "anim/entityanim.h" #include "interact/entityinteract.h" #include "entitytype.h" #include "npc/npc.h" +#include "rpg/physics/physicsbody.h" +#include "rpg/physics/physicsworld.h" typedef struct map_s map_t; @@ -20,6 +21,29 @@ typedef uint16_t entityglobalid_t; #define ENTITY_GLOBAL_ID_START 1 #define ENTITY_GLOBAL_ID_PLAYER 1 +// Default collision box for every entity's physics body - matches the old +// system's exact one-tile footprint. +#define ENTITY_PHYSICS_EXTENTS_DEFAULT { 1.0f, 1.0f, 1.0f } + +// Movement speeds, in grid units per second - chosen to match the feel of +// the old fixed-duration one-tile-per-step system (12 ticks/tile walking, +// 6 ticks/tile running, at DUSK_TIME_STEP = 16ms). +#define ENTITY_MOVE_WALK_SPEED 5.2083f +#define ENTITY_MOVE_RUN_SPEED 10.4167f + +// Movement vectors below this magnitude are treated as no movement. +#define ENTITY_MOVE_DEADZONE 0.1f + +// How far in front of an entity entityGetFacing probes for a target. +#define ENTITY_INTERACT_RANGE 1.0f + +typedef enum { + ENTITY_ANIM_IDLE, + ENTITY_ANIM_WALK, + ENTITY_ANIM_RUN, + ENTITY_ANIM_COUNT +} entityanim_t; + typedef struct entity_s { uint8_t id; entityglobalid_t globalId; @@ -28,13 +52,18 @@ typedef struct entity_s { // Movement entitydir_t direction; + physicsbody_t body; + + // Derived each frame from body.position (floored) - kept for systems + // that still assume an integer grid position (chunk membership, map + // area triggers, entity-at-position queries). worldpos_t position; - worldpos_t lastPosition; + + // Derived each frame from body.position - mirrors the physics position + // into render/world-float space (z scaled by WORLD_LAYER_HEIGHT). vec3 renderPosition; entityanim_t animation; - float_t animTime; - float_t walkEndCooldown; entityinteract_t interact; @@ -43,6 +72,9 @@ typedef struct entity_s { extern entity_t ENTITIES[ENTITY_COUNT]; +// Shared physics world every entity's body steps against. +extern physicsworld_t ENTITY_PHYSICS_WORLD; + /** * Initializes an entity structure. * @@ -58,30 +90,6 @@ void entityInit(entity_t *entity, const entitytype_t type); */ void entityUpdate(entity_t *entity); -/** - * Returns true if the entity is in a state where it can turn. - * - * @param entity Pointer to the entity to check. - * @returns True if the entity can turn. - */ -bool_t entityCanTurn(entity_t *entity); - -/** - * Returns true if the entity is in a state where it can walk. - * - * @param entity Pointer to the entity to check. - * @returns True if the entity can walk. - */ -bool_t entityCanWalk(entity_t *entity); - -/** - * Returns true if the entity is in a state where it can run. - * - * @param entity Pointer to the entity to check. - * @returns True if the entity can run. - */ -bool_t entityCanRun(entity_t *entity); - /** * Returns true if the entity is allowed to be unloaded. By default this is * true for entities whose global ID falls within the randomly assigned @@ -93,7 +101,29 @@ bool_t entityCanRun(entity_t *entity); bool_t entityCanUnload(entity_t *entity); /** - * Turn an entity to face a new direction. + * Moves an entity continuously in a direction, at walking or running + * speed. Sets the entity's facing to the nearest cardinal direction that + * matches the movement vector. Must be called every tick the entity + * should keep moving - unlike the old tile-stepping system this does not + * complete a move on its own; call entityStop to stop. + * + * @param entity Pointer to the entity to move. + * @param direction Movement vector, magnitude 0-1 (values longer than 1 + * are clamped to 1, so diagonals aren't faster than cardinals). + * @param running Whether to move at running speed instead of walking. + */ +void entityMove(entity_t *entity, const vec2 direction, const bool_t running); + +/** + * Stops an entity's horizontal movement (equivalent to + * entityMove(entity, {0, 0}, false)). + * + * @param entity Pointer to the entity to stop. + */ +void entityStop(entity_t *entity); + +/** + * Turn an entity to face a new direction, instantly, without moving it. * * @param entity Pointer to the entity to turn. * @param direction The direction to face. @@ -101,7 +131,10 @@ bool_t entityCanUnload(entity_t *entity); void entityTurn(entity_t *entity, const entitydir_t direction); /** - * Make an entity walk in a direction. + * Makes an entity walk continuously in a cardinal direction, at walking + * speed. Convenience wrapper over entityMove for callers that only think + * in terms of the 4 cardinal directions. Must be called every tick the + * entity should keep moving. * * @param entity Pointer to the entity to make walk. * @param direction The direction to walk in. @@ -109,7 +142,8 @@ void entityTurn(entity_t *entity, const entitydir_t direction); void entityWalk(entity_t *entity, const entitydir_t direction); /** - * Make an entity run in a direction. + * Makes an entity walk continuously in a cardinal direction, at running + * speed. See entityWalk. * * @param entity Pointer to the entity to make run. * @param direction The direction to run in. @@ -117,13 +151,16 @@ void entityWalk(entity_t *entity, const entitydir_t direction); void entityRun(entity_t *entity, const entitydir_t direction); /** - * Gets the entity at a specific world position. + * Finds the closest other entity whose bounds overlap a probe box + * projected out from the given entity's own bounds, along its current + * facing direction. Used for interaction targeting - continuous-position + * aware, unlike an exact tile match. * - * @param map Pointer to the map to check. - * @param pos The world position to check. - * @return Pointer to the entity at the position, or NULL if none. + * @param entity Pointer to the entity to probe from. + * @param range Distance, in grid units, to project the probe box. + * @return Pointer to the closest overlapping entity, or NULL if none. */ -entity_t *entityGetAt(const worldpos_t pos); +entity_t *entityGetFacing(entity_t *entity, const float_t range); /** * Gets the entity with the given global ID, if one is currently loaded. @@ -164,4 +201,13 @@ void entityUpdateChunk(entity_t *entity); * @param entity Pointer to the entity to move. * @param pos The world position to place the entity at. */ -void entityPositionSet(entity_t *entity, const worldpos_t pos); \ No newline at end of file +void entityPositionSet(entity_t *entity, const worldpos_t pos); + +/** + * Derives position and renderPosition from the entity's physics body, and + * refreshes chunk membership and map area triggers to match. Called once + * per entity per frame, after the physics step. + * + * @param entity Pointer to the entity to sync. + */ +void entitySyncFromPhysics(entity_t *entity); \ No newline at end of file diff --git a/src/dusk/rpg/entity/entitydir.c b/src/dusk/rpg/entity/entitydir.c index bf75e7c7..da1de144 100644 --- a/src/dusk/rpg/entity/entitydir.c +++ b/src/dusk/rpg/entity/entitydir.c @@ -48,4 +48,27 @@ void entityDirGetRelative( *outY = 0; break; } +} + +void entityDirToVec2(const entitydir_t dir, vec2 out) { + assertValidEntityDir(dir, "Invalid direction provided"); + assertNotNull(out, "Output vector cannot be NULL"); + + worldunits_t relX, relY; + entityDirGetRelative(dir, &relX, &relY); + out[0] = (float_t)relX; + out[1] = (float_t)relY; +} + +entitydir_t entityDirFromVec2(const vec2 direction) { + assertNotNull(direction, "Direction vector cannot be NULL"); + assertTrue( + direction[0] != 0.0f || direction[1] != 0.0f, + "Direction vector cannot be zero" + ); + + if(fabsf(direction[0]) > fabsf(direction[1])) { + return direction[0] > 0.0f ? ENTITY_DIR_EAST : ENTITY_DIR_WEST; + } + return direction[1] > 0.0f ? ENTITY_DIR_NORTH : ENTITY_DIR_SOUTH; } \ No newline at end of file diff --git a/src/dusk/rpg/entity/entitydir.h b/src/dusk/rpg/entity/entitydir.h index 2eefab88..19d2fda3 100644 --- a/src/dusk/rpg/entity/entitydir.h +++ b/src/dusk/rpg/entity/entitydir.h @@ -44,11 +44,30 @@ entitydir_t entityDirGetOpposite(const entitydir_t dir); /** * Gets the relative x and y offsets for a given direction. - * + * * @param dir The direction to get offsets for. * @param relX Pointer to store the relative x offset. * @param relY Pointer to store the relative y offset. */ void entityDirGetRelative( const entitydir_t dir, worldunits_t *relX, worldunits_t *relY -); \ No newline at end of file +); + +/** + * Converts a cardinal direction to a unit 2D vector, using the same axis + * convention as entityDirGetRelative (north = +y, east = +x). + * + * @param dir The direction to convert. + * @param out Output unit vector for the direction. + */ +void entityDirToVec2(const entitydir_t dir, vec2 out); + +/** + * Quantizes a 2D direction vector to the nearest cardinal direction, by + * comparing the magnitude of its x and y components. + * + * @param direction The direction vector to quantize. Must not be a zero + * vector. + * @return The nearest cardinal direction. + */ +entitydir_t entityDirFromVec2(const vec2 direction); \ No newline at end of file diff --git a/src/dusk/rpg/entity/entitypathstep.c b/src/dusk/rpg/entity/entitypathstep.c index bd474415..fbbd69be 100644 --- a/src/dusk/rpg/entity/entitypathstep.c +++ b/src/dusk/rpg/entity/entitypathstep.c @@ -9,6 +9,10 @@ #include "entitydir.h" #include "assert/assert.h" +// Distance, in grid units, within which the entity is considered to have +// arrived at its path target. +#define ENTITY_PATH_ARRIVE_EPSILON 0.05f + bool_t entityPathStep( entity_t *entity, const worldpos_t target, @@ -24,36 +28,44 @@ bool_t entityPathStep( "Entity pointer is out of bounds" ); - if(worldPosIsEqual(entity->position, target) && entityCanWalk(entity)) { + const float_t dx = (float_t)target.x - entity->body.position[0]; + const float_t dy = (float_t)target.y - entity->body.position[1]; + + if( + fabsf(dx) <= ENTITY_PATH_ARRIVE_EPSILON && + fabsf(dy) <= ENTITY_PATH_ARRIVE_EPSILON + ) { + entityStop(entity); return true; } - if(!entityCanWalk(entity)) return false; entitydir_t dir; - if(entity->position.x != target.x) { - dir = entity->position.x < target.x ? ENTITY_DIR_EAST : ENTITY_DIR_WEST; - } else if(entity->position.y != target.y) { - dir = entity->position.y < target.y ? ENTITY_DIR_NORTH : ENTITY_DIR_SOUTH; + bool_t horizontal; + if(fabsf(dx) > fabsf(dy)) { + dir = dx > 0.0f ? ENTITY_DIR_EAST : ENTITY_DIR_WEST; + horizontal = true; } else { - dir = entity->position.z < target.z ? ENTITY_DIR_NORTH : ENTITY_DIR_SOUTH; + dir = dy > 0.0f ? ENTITY_DIR_NORTH : ENTITY_DIR_SOUTH; + horizontal = false; + } + + // Was the entity trying to walk on this axis last tick, but ended up not + // moving (velocity clamped by a collision)? If so, treat it as blocked. + if(walkAround) { + const uint8_t axis = horizontal ? 0 : 1; + const bool_t blockedLastTick = + entity->animation != ENTITY_ANIM_IDLE && + fabsf(entity->body.velocity[axis]) <= ENTITY_MOVE_DEADZONE; + + if(blockedLastTick) { + const entitydir_t alt = horizontal + ? (dy >= 0.0f ? ENTITY_DIR_NORTH : ENTITY_DIR_SOUTH) + : (dx >= 0.0f ? ENTITY_DIR_EAST : ENTITY_DIR_WEST); + entityWalk(entity, alt); + return false; + } } entityWalk(entity, dir); - - if(walkAround && entity->animation == ENTITY_ANIM_IDLE) { - // Primary direction blocked - try perpendicular axes. - entitydir_t alt, altOpp; - if(dir == ENTITY_DIR_EAST || dir == ENTITY_DIR_WEST) { - alt = entity->position.y <= target.y - ? ENTITY_DIR_NORTH : ENTITY_DIR_SOUTH; - } else { - alt = entity->position.x <= target.x - ? ENTITY_DIR_EAST : ENTITY_DIR_WEST; - } - altOpp = entityDirGetOpposite(alt); - entityWalk(entity, alt); - if(entity->animation == ENTITY_ANIM_IDLE) entityWalk(entity, altOpp); - } - return false; } diff --git a/src/dusk/rpg/entity/entitypathstep.h b/src/dusk/rpg/entity/entitypathstep.h index 1608cae8..ca8041b9 100644 --- a/src/dusk/rpg/entity/entitypathstep.h +++ b/src/dusk/rpg/entity/entitypathstep.h @@ -9,15 +9,17 @@ #include "entity.h" /** - * Attempts to walk the entity one tile toward target. Prefers resolving X - * first, then Y, then Z. Does nothing if the entity cannot currently walk. - * When walkAround is true and the preferred direction is blocked by an entity, - * tries perpendicular directions to navigate around it. + * Continuously walks the entity toward target's X/Y position (Z is left to + * gravity/ground collision, not deliberate path-stepping). Prefers closing + * whichever of X/Y currently differs most. Must be called every tick until + * it returns true. When walkAround is true and the last tick's movement on + * the current axis was blocked (its velocity on that axis reads as zero + * despite currently walking), tries a perpendicular direction instead. * * @param entity Pointer to the entity to move. - * @param target The world position to move toward. - * @param walkAround Whether to try perpendicular directions when blocked. - * @returns true if the entity is already at target, false otherwise. + * @param target The world position to move toward (Z is ignored). + * @param walkAround Whether to try a perpendicular direction when blocked. + * @returns true once the entity is within arrival distance of target. */ bool_t entityPathStep( entity_t *entity, diff --git a/src/dusk/rpg/entity/npc/npcpath.c b/src/dusk/rpg/entity/npc/npcpath.c index 9d6ea47d..e55740db 100644 --- a/src/dusk/rpg/entity/npc/npcpath.c +++ b/src/dusk/rpg/entity/npc/npcpath.c @@ -29,15 +29,9 @@ void npcPathAddNode(npc_t *npc, const worldpos_t pos) { void npcPathMovement(entity_t *entity) { npcpath_t *path = &entity->data.npc.moveData.path; if(path->count == 0) return; - if(!entityCanWalk(entity)) return; - // Advance past any waypoints already reached (including the current one). - worldpos_t *target = &path->positions[path->index]; - if(entityPathStep(entity, *target, false)) { + const worldpos_t target = path->positions[path->index]; + if(entityPathStep(entity, target, false)) { path->index = (path->index + 1) % path->count; - target = &path->positions[path->index]; - // New target is the same tile - nothing to do this tick - if(worldPosIsEqual(entity->position, *target)) return; - entityPathStep(entity, *target, false); } } diff --git a/src/dusk/rpg/entity/npc/npcturn.c b/src/dusk/rpg/entity/npc/npcturn.c index 63654ab3..cdbaccc9 100644 --- a/src/dusk/rpg/entity/npc/npcturn.c +++ b/src/dusk/rpg/entity/npc/npcturn.c @@ -23,5 +23,5 @@ void npcRandomTurnMovement(entity_t *entity) { turn->timer -= TIME.delta; if(turn->timer > 0.0f) return; turn->timer = randomFloat(turn->frequencyMin, turn->frequencyMax); - if(entityCanTurn(entity)) entityTurn(entity, (entitydir_t)(rand() % 4)); + entityTurn(entity, (entitydir_t)(rand() % 4)); } diff --git a/src/dusk/rpg/entity/npc/npcwalk.c b/src/dusk/rpg/entity/npc/npcwalk.c index dc8794ec..7d70feb7 100644 --- a/src/dusk/rpg/entity/npc/npcwalk.c +++ b/src/dusk/rpg/entity/npc/npcwalk.c @@ -16,14 +16,24 @@ void npcRandomWalkInit(npc_t *npc) { walk->frequencyMin = NPC_RANDOM_WALK_FREQUENCY_MIN_DEFAULT; walk->frequencyMax = NPC_RANDOM_WALK_FREQUENCY_MAX_DEFAULT; walk->timer = randomFloat(walk->frequencyMin, walk->frequencyMax); + walk->moveDuration = 0.0f; } void npcRandomWalkMovement(entity_t *entity) { npcrandomwalk_t *walk = &entity->data.npc.moveData.randomWalk; + + if(walk->moveDuration > 0.0f) { + walk->moveDuration -= TIME.delta; + entityWalk(entity, walk->direction); + if(walk->moveDuration <= 0.0f) entityStop(entity); + return; + } + walk->timer -= TIME.delta; if(walk->timer > 0.0f) return; walk->timer = randomFloat(walk->frequencyMin, walk->frequencyMax); - if(entityCanWalk(entity)) entityWalk(entity, (entitydir_t)(rand() % 4)); + walk->direction = (entitydir_t)(rand() % 4); + walk->moveDuration = NPC_RANDOM_WALK_MOVE_DURATION_DEFAULT; } void npcRandomTurnAndWalkInit(npc_t *npc) { @@ -32,21 +42,30 @@ void npcRandomTurnAndWalkInit(npc_t *npc) { tw->walk.frequencyMin = NPC_RANDOM_WALK_FREQUENCY_MIN_DEFAULT; tw->walk.frequencyMax = NPC_RANDOM_WALK_FREQUENCY_MAX_DEFAULT; tw->walk.timer = randomFloat(tw->walk.frequencyMin, tw->walk.frequencyMax); + tw->walk.moveDuration = 0.0f; } void npcRandomTurnAndWalkMovement(entity_t *entity) { npcrandomturnandwalk_t *tw = &entity->data.npc.moveData.randomTurnAndWalk; + if(tw->walk.moveDuration > 0.0f) { + tw->walk.moveDuration -= TIME.delta; + entityWalk(entity, tw->walk.direction); + if(tw->walk.moveDuration <= 0.0f) entityStop(entity); + return; + } + tw->turn.timer -= TIME.delta; if(tw->turn.timer <= 0.0f) { tw->turn.timer = randomFloat(tw->turn.frequencyMin, tw->turn.frequencyMax); - if(entityCanTurn(entity)) entityTurn(entity, (entitydir_t)(rand() % 4)); + entityTurn(entity, (entitydir_t)(rand() % 4)); } tw->walk.timer -= TIME.delta; if(tw->walk.timer <= 0.0f) { tw->walk.timer = randomFloat(tw->walk.frequencyMin, tw->walk.frequencyMax); - if(entityCanWalk(entity)) entityWalk(entity, (entitydir_t)(rand() % 4)); + tw->walk.direction = (entitydir_t)(rand() % 4); + tw->walk.moveDuration = NPC_RANDOM_WALK_MOVE_DURATION_DEFAULT; } } diff --git a/src/dusk/rpg/entity/npc/npcwalk.h b/src/dusk/rpg/entity/npc/npcwalk.h index 4a65c4f8..36dbb0f9 100644 --- a/src/dusk/rpg/entity/npc/npcwalk.h +++ b/src/dusk/rpg/entity/npc/npcwalk.h @@ -8,15 +8,25 @@ #pragma once #include "dusk.h" #include "npcturn.h" +#include "rpg/entity/entitydir.h" /** Default min/max seconds between NPC random-walk ticks. */ #define NPC_RANDOM_WALK_FREQUENCY_MIN_DEFAULT 2.0f #define NPC_RANDOM_WALK_FREQUENCY_MAX_DEFAULT 5.0f +/** How long a random-walk move lasts once triggered, roughly one tile's + * worth of travel at walking speed. */ +#define NPC_RANDOM_WALK_MOVE_DURATION_DEFAULT 0.2f + typedef struct { float_t frequencyMin; float_t frequencyMax; float_t timer; + + // In-progress move state - while moveDuration > 0, movement keeps + // walking in direction every tick. + float_t moveDuration; + entitydir_t direction; } npcrandomwalk_t; typedef struct { diff --git a/src/dusk/rpg/entity/player.c b/src/dusk/rpg/entity/player.c index 632f726b..fff0360c 100644 --- a/src/dusk/rpg/entity/player.c +++ b/src/dusk/rpg/entity/player.c @@ -25,8 +25,11 @@ bool_t playerCanInteract(entity_t *entity) { void playerInput(entity_t *entity) { assertNotNull(entity, "Entity pointer cannot be NULL"); - if(CUTSCENE_SYSTEM.pause & CUTSCENE_PAUSE_PLAYER) return; - + if(CUTSCENE_SYSTEM.pause & CUTSCENE_PAUSE_PLAYER) { + entityStop(entity); + return; + } + // Toggle game menu on pause if(uiGameMenuIsOpen() && inputPressed(INPUT_ACTION_PAUSE)) { uiGameMenuClose(); @@ -38,63 +41,27 @@ void playerInput(entity_t *entity) { } // Can player act? - if(UI_FOCUS.count > 0) return; - - // Determine direction player may be trying to face/go. - const playerinputdirmap_t *dirMap; - - // First pass, we want to prefer a button relative to what the player is - // already facing, e.g. if they hold DOWN and start moving down, then tap - // right we want to prefer down over right. - - // Determine current dirmap based on facing direction. - dirMap = PLAYER_INPUT_DIR_MAP; - do { - if(dirMap->direction != entity->direction) continue; - break; - } while((++dirMap)->action != 0xFF); - - // Are we holding that button? - if(!inputIsDown(dirMap->action)) { - // No, so we need to check all directions. - dirMap = PLAYER_INPUT_DIR_MAP; - do { - if(!inputIsDown(dirMap->action)) continue; - break; - } while((++dirMap)->action != 0xFF); + if(UI_FOCUS.count > 0) { + entityStop(entity); + return; } - // Trying to direct? - if(dirMap->action != 0xFF) { - // Is the player attempting to turn? That is, is the player idle and has the - // turn lockout passed? - if(entityCanTurn(entity)) { - if(entity->direction != dirMap->direction) { - entityTurn(entity, dirMap->direction); - return; - } - } + // Analog/free-angle movement vector from the 4 directional actions - + // already normalized so diagonals aren't faster than cardinals, and + // preserves real analog magnitude on platforms that bind an actual + // gamepad stick to these actions. + vec2 moveDir; + inputAngle2D( + INPUT_ACTION_LEFT, INPUT_ACTION_RIGHT, + INPUT_ACTION_DOWN, INPUT_ACTION_UP, + moveDir + ); - bool_t running = inputIsDown(INPUT_ACTION_CANCEL); - if(running && entityCanRun(entity)) { - entityRun(entity, dirMap->direction); - } else if(entityCanWalk(entity)) { - entityWalk(entity, dirMap->direction); - } - } + entityMove(entity, moveDir, inputIsDown(INPUT_ACTION_CANCEL)); // Interaction if(inputPressed(INPUT_ACTION_ACCEPT) && playerCanInteract(entity)) { - worldunit_t x, y, z; - { - worldunits_t relX, relY; - entityDirGetRelative(entity->direction, &relX, &relY); - x = entity->position.x + relX; - y = entity->position.y + relY; - z = entity->position.z; - } - - entity_t *target = entityGetAt((worldpos_t){ x, y, z }); + entity_t *target = entityGetFacing(entity, ENTITY_INTERACT_RANGE); if(target == NULL) return; entityInteractWith(entity, target); } diff --git a/src/dusk/rpg/entity/player.h b/src/dusk/rpg/entity/player.h index 13e369d1..24fc683e 100644 --- a/src/dusk/rpg/entity/player.h +++ b/src/dusk/rpg/entity/player.h @@ -15,20 +15,6 @@ typedef struct { void *nothing; } player_t; -typedef struct { - inputaction_t action; - entitydir_t direction; -} playerinputdirmap_t; - -static const playerinputdirmap_t PLAYER_INPUT_DIR_MAP[] = { - { INPUT_ACTION_UP, ENTITY_DIR_NORTH }, - { INPUT_ACTION_DOWN, ENTITY_DIR_SOUTH }, - { INPUT_ACTION_LEFT, ENTITY_DIR_WEST }, - { INPUT_ACTION_RIGHT, ENTITY_DIR_EAST }, - - { 0xFF, 0xFF } -}; - /** * Initializes a player entity. * diff --git a/src/dusk/rpg/overworld/map.c b/src/dusk/rpg/overworld/map.c index 20ad8de1..a64556be 100644 --- a/src/dusk/rpg/overworld/map.c +++ b/src/dusk/rpg/overworld/map.c @@ -168,6 +168,14 @@ errorret_t mapChunkLoad(chunk_t *chunk) { errorOk(); } + // Placeholder-fill with flat ground while the real data streams in + // asynchronously - entities standing on this chunk (subject to gravity) + // would otherwise fall through the zeroed/empty tiles until + // mapChunkLoaded() replaces them with the real data. + for(uint32_t i = 0; i < CHUNK_TILE_COUNT; i++) { + chunk->tiles[i] = (tile_t){ .shape = TILE_SHAPE_GROUND }; + } + assertTrue( MAP.loadQueueCount < MAP_CHUNK_COUNT, "Chunk load queue overflow" diff --git a/src/dusk/rpg/overworld/maparea.c b/src/dusk/rpg/overworld/maparea.c index 95efe628..562afaa6 100644 --- a/src/dusk/rpg/overworld/maparea.c +++ b/src/dusk/rpg/overworld/maparea.c @@ -38,6 +38,7 @@ void mapAreaInit( area->triggerCount = 0; memorySet(area->entities, 0, sizeof(area->entities)); + memorySet(area->lastStepPosition, 0, sizeof(area->lastStepPosition)); } bool_t mapAreaIsInside(const maparea_t *area, const worldpos_t position) { @@ -132,9 +133,13 @@ void mapAreaCheckEntity(entity_t *entity) { if(isInside && !wasInside) { area->entities[entity->id] = 1; + area->lastStepPosition[entity->id] = entity->position; trigger = MAP_TRIGGER_ENTER; } else if(isInside && wasInside) { - trigger = MAP_TRIGGER_STEP; + if(!worldPosIsEqual(entity->position, area->lastStepPosition[entity->id])) { + area->lastStepPosition[entity->id] = entity->position; + trigger = MAP_TRIGGER_STEP; + } } else if(!isInside && wasInside) { area->entities[entity->id] = 0; trigger = MAP_TRIGGER_EXIT; diff --git a/src/dusk/rpg/overworld/maparea.h b/src/dusk/rpg/overworld/maparea.h index 836f712a..f4d88dc9 100644 --- a/src/dusk/rpg/overworld/maparea.h +++ b/src/dusk/rpg/overworld/maparea.h @@ -18,6 +18,8 @@ typedef struct maparea_s maparea_t; #define MAP_AREA_NOTIFY_NPC (1 << 1) #define MAP_AREA_NOTIFY_ALL (MAP_AREA_NOTIFY_PLAYER | MAP_AREA_NOTIFY_NPC) +// Fires once per newly-entered tile while an entity remains inside the +// area (not once per frame) - see mapAreaCheckEntity. #define MAP_TRIGGER_STEP (1 << 0) #define MAP_TRIGGER_ENTER (1 << 1) #define MAP_TRIGGER_EXIT (1 << 2) @@ -39,6 +41,11 @@ typedef struct maparea_s { uint8_t trigger; uint8_t entities[ENTITY_COUNT]; + // The floored tile position ENTER or STEP last fired at, per entity. + // Used to fire STEP only once per newly-entered tile rather than + // every frame the entity remains inside the area. + worldpos_t lastStepPosition[ENTITY_COUNT]; + // Incremented every time this area's callback is invoked. Lets other // systems (e.g. cutscenes) detect "has this area fired since I last // checked" without needing to be the callback themselves. @@ -143,8 +150,10 @@ void mapAreaRemove(const uint8_t id); * Checks every active map area against an entity's current position, * for areas whose notify flags include the entity's type. Invokes the * area's callback with MAP_TRIGGER_ENTER the frame the entity first - * becomes inside, MAP_TRIGGER_STEP on every subsequent frame it remains - * inside, and MAP_TRIGGER_EXIT the frame it leaves. + * becomes inside, MAP_TRIGGER_STEP the frame it moves into a different + * tile while remaining inside, and MAP_TRIGGER_EXIT the frame it leaves. + * Safe (and expected) to call every frame - trigger conditions are + * edge-detected against the entity's tile position, not the frame rate. * * @param entity Pointer to the entity to check. */ diff --git a/src/dusk/rpg/physics/physicsworld.c b/src/dusk/rpg/physics/physicsworld.c index 1aba803c..c3fce02f 100644 --- a/src/dusk/rpg/physics/physicsworld.c +++ b/src/dusk/rpg/physics/physicsworld.c @@ -31,7 +31,11 @@ void physicsWorldInit( } void physicsWorldStep( - const physicsworld_t *world, physicsbody_t *body, const float_t dt + const physicsworld_t *world, + physicsbody_t *body, + const float_t dt, + physicsbody_t * const *others, + const uint32_t othersCount ) { assertNotNull(world, "world must not be null"); assertNotNull(body, "body must not be null"); @@ -41,13 +45,17 @@ void physicsWorldStep( body->velocity[2], -world->terminalVelocity, world->terminalVelocity ); - physicsWorldResolveAxisX(world, body, dt); - physicsWorldResolveAxisY(world, body, dt); - physicsWorldResolveAxisZ(world, body, dt); + physicsWorldResolveAxisX(world, body, dt, others, othersCount); + physicsWorldResolveAxisY(world, body, dt, others, othersCount); + physicsWorldResolveAxisZ(world, body, dt, others, othersCount); } void physicsWorldResolveAxisX( - const physicsworld_t *world, physicsbody_t *body, const float_t dt + const physicsworld_t *world, + physicsbody_t *body, + const float_t dt, + physicsbody_t * const *others, + const uint32_t othersCount ) { assertNotNull(world, "world must not be null"); assertNotNull(body, "body must not be null"); @@ -83,6 +91,7 @@ void physicsWorldResolveAxisX( if(blocked) { body->position[0] = (float_t)col - body->extents[0]; body->velocity[0] = 0.0f; + physicsWorldResolveBodyOverlap(body, 0, others, othersCount); return; } } @@ -108,16 +117,23 @@ void physicsWorldResolveAxisX( if(blocked) { body->position[0] = (float_t)(col + 1); body->velocity[0] = 0.0f; + physicsWorldResolveBodyOverlap(body, 0, others, othersCount); return; } } body->position[0] += vx * dt; } + + physicsWorldResolveBodyOverlap(body, 0, others, othersCount); } void physicsWorldResolveAxisY( - const physicsworld_t *world, physicsbody_t *body, const float_t dt + const physicsworld_t *world, + physicsbody_t *body, + const float_t dt, + physicsbody_t * const *others, + const uint32_t othersCount ) { assertNotNull(world, "world must not be null"); assertNotNull(body, "body must not be null"); @@ -153,6 +169,7 @@ void physicsWorldResolveAxisY( if(blocked) { body->position[1] = (float_t)row - body->extents[1]; body->velocity[1] = 0.0f; + physicsWorldResolveBodyOverlap(body, 1, others, othersCount); return; } } @@ -178,16 +195,23 @@ void physicsWorldResolveAxisY( if(blocked) { body->position[1] = (float_t)(row + 1); body->velocity[1] = 0.0f; + physicsWorldResolveBodyOverlap(body, 1, others, othersCount); return; } } body->position[1] += vy * dt; } + + physicsWorldResolveBodyOverlap(body, 1, others, othersCount); } void physicsWorldResolveAxisZ( - const physicsworld_t *world, physicsbody_t *body, const float_t dt + const physicsworld_t *world, + physicsbody_t *body, + const float_t dt, + physicsbody_t * const *others, + const uint32_t othersCount ) { assertNotNull(world, "world must not be null"); assertNotNull(body, "body must not be null"); @@ -244,4 +268,40 @@ void physicsWorldResolveAxisZ( body->position[2] += vz * dt; } } + + physicsWorldResolveBodyOverlap(body, 2, others, othersCount); +} + +void physicsWorldResolveBodyOverlap( + physicsbody_t *body, + const uint8_t axis, + physicsbody_t * const *others, + const uint32_t othersCount +) { + assertNotNull(body, "body must not be null"); + assertTrue(axis < 3, "axis must be 0, 1 or 2"); + + const float_t v = body->velocity[axis]; + if(v == 0.0f) return; + + for(uint32_t i = 0; i < othersCount; i++) { + physicsbody_t *other = others[i]; + if(other == NULL || other == body) continue; + + vec3 min, max; + physicsBodyGetBounds(body, min, max); + vec3 oMin, oMax; + physicsBodyGetBounds(other, oMin, oMax); + + if(min[0] >= oMax[0] || max[0] <= oMin[0]) continue; + if(min[1] >= oMax[1] || max[1] <= oMin[1]) continue; + if(min[2] >= oMax[2] || max[2] <= oMin[2]) continue; + + if(v > 0.0f) { + body->position[axis] -= max[axis] - oMin[axis]; + } else { + body->position[axis] += oMax[axis] - min[axis]; + } + body->velocity[axis] = 0.0f; + } } diff --git a/src/dusk/rpg/physics/physicsworld.h b/src/dusk/rpg/physics/physicsworld.h index 2683bea1..f0b348bd 100644 --- a/src/dusk/rpg/physics/physicsworld.h +++ b/src/dusk/rpg/physics/physicsworld.h @@ -41,7 +41,10 @@ void physicsWorldInit( * Advances a body by one timestep: applies gravity, integrates velocity * into position (semi-implicit Euler), and resolves collisions against * the tile map one axis at a time (X, then Y, then Z), clamping position - * and zeroing velocity on any axis that hits a tile boundary. + * and zeroing velocity on any axis that hits a tile boundary. After each + * axis's tile resolution, also resolves overlap against any given other + * bodies (see physicsWorldResolveBodyOverlap) - so another body is + * treated as solid too, not just the tile map. * * Known limitations, deliberately out of scope for this very basic pass: * - No ramp/slope support - ramp tiles are treated as flat walkable @@ -52,28 +55,44 @@ void physicsWorldInit( * - 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. + * - Body-vs-body resolution only reacts to the axis currently being + * resolved - two bodies already overlapping on every axis, with no + * velocity on any of them, are never proactively separated. * * @param world Physics world configuration. * @param body The body to step. Its position/velocity/grounded fields are * updated in place. * @param dt Timestep, in seconds (use DUSK_TIME_STEP for the fixed step). + * @param others Array of pointers to other bodies to treat as solid. + * Pass NULL (with othersCount 0) to resolve against the tile map only. + * @param othersCount Number of entries in others. */ void physicsWorldStep( - const physicsworld_t *world, physicsbody_t *body, const float_t dt + const physicsworld_t *world, + physicsbody_t *body, + const float_t dt, + physicsbody_t * const *others, + const uint32_t othersCount ); /** * Resolves the body's movement along the X axis for this step, clamping - * position and zeroing velocity.x if a non-walkable column blocks the - * move. Declared publicly as an internal step helper, not a stable public - * API on its own. + * position and zeroing velocity.x if a non-walkable column, or another + * body, blocks the move. Declared publicly as an internal step helper, + * not a stable public API on its own. * * @param world Physics world configuration. * @param body The body to resolve. * @param dt Timestep, in seconds. + * @param others Array of pointers to other bodies to treat as solid. + * @param othersCount Number of entries in others. */ void physicsWorldResolveAxisX( - const physicsworld_t *world, physicsbody_t *body, const float_t dt + const physicsworld_t *world, + physicsbody_t *body, + const float_t dt, + physicsbody_t * const *others, + const uint32_t othersCount ); /** @@ -83,9 +102,15 @@ void physicsWorldResolveAxisX( * @param world Physics world configuration. * @param body The body to resolve. * @param dt Timestep, in seconds. + * @param others Array of pointers to other bodies to treat as solid. + * @param othersCount Number of entries in others. */ void physicsWorldResolveAxisY( - const physicsworld_t *world, physicsbody_t *body, const float_t dt + const physicsworld_t *world, + physicsbody_t *body, + const float_t dt, + physicsbody_t * const *others, + const uint32_t othersCount ); /** @@ -96,7 +121,34 @@ void physicsWorldResolveAxisY( * @param world Physics world configuration. * @param body The body to resolve. * @param dt Timestep, in seconds. + * @param others Array of pointers to other bodies to treat as solid. + * @param othersCount Number of entries in others. */ void physicsWorldResolveAxisZ( - const physicsworld_t *world, physicsbody_t *body, const float_t dt + const physicsworld_t *world, + physicsbody_t *body, + const float_t dt, + physicsbody_t * const *others, + const uint32_t othersCount +); + +/** + * Resolves the body's overlap against a set of other bodies along a + * single axis: if the body's bounds fully overlap (on all 3 axes) + * another body's bounds, pushes the body back along the given axis by + * the overlap depth on that axis, in the opposite direction of its + * current velocity on that axis, and zeroes that velocity component. + * Does nothing if the body has no velocity on that axis, since there is + * then no direction to know which way to push it out. + * + * @param body The body to resolve. + * @param axis The axis to resolve overlap on (0 = x, 1 = y, 2 = z). + * @param others Array of pointers to other bodies to check against. + * @param othersCount Number of entries in others. + */ +void physicsWorldResolveBodyOverlap( + physicsbody_t *body, + const uint8_t axis, + physicsbody_t * const *others, + const uint32_t othersCount ); diff --git a/src/dusk/rpg/rpg.c b/src/dusk/rpg/rpg.c index 08d7cd1a..2111737a 100644 --- a/src/dusk/rpg/rpg.c +++ b/src/dusk/rpg/rpg.c @@ -32,6 +32,12 @@ errorret_t rpgInit(void) { memoryZero(ENTITIES, sizeof(ENTITIES)); memoryZero(MAP_AREAS, sizeof(MAP_AREAS)); + physicsWorldInit( + &ENTITY_PHYSICS_WORLD, + PHYSICS_WORLD_GRAVITY_DEFAULT, + PHYSICS_WORLD_TERMINAL_VELOCITY_DEFAULT + ); + backpackInit(); cutsceneSystemInit(); diff --git a/src/dusk/ui/debug/uiplayerpos.c b/src/dusk/ui/debug/uiplayerpos.c index 29cc8a75..2d757b07 100644 --- a/src/dusk/ui/debug/uiplayerpos.c +++ b/src/dusk/ui/debug/uiplayerpos.c @@ -44,5 +44,21 @@ errorret_t uiPlayerPosDraw() { errorChain(textDraw( (float_t)SCREEN.scanX, y, text, COLOR_GREEN, &FONT_DEFAULT )); + + char_t velocityText[64]; + snprintf( + velocityText, + sizeof(velocityText), + "vel %.2f,%.2f,%.2f", + player->body.velocity[0], + player->body.velocity[1], + player->body.velocity[2] + ); + + float_t velocityY = y + (float_t)FONT_DEFAULT.tileset->tileHeight; + errorChain(textDraw( + (float_t)SCREEN.scanX, velocityY, velocityText, COLOR_GREEN, &FONT_DEFAULT + )); + return spriteBatchFlush(); } diff --git a/test/rpg/CMakeLists.txt b/test/rpg/CMakeLists.txt index f80ff20e..d4d1190c 100644 --- a/test/rpg/CMakeLists.txt +++ b/test/rpg/CMakeLists.txt @@ -10,4 +10,5 @@ dusktest(test_rpg.c) # Subdirs add_subdirectory(overworld) -add_subdirectory(physics) \ No newline at end of file +add_subdirectory(physics) +add_subdirectory(entity) \ No newline at end of file diff --git a/test/rpg/entity/CMakeLists.txt b/test/rpg/entity/CMakeLists.txt new file mode 100644 index 00000000..e1c5be20 --- /dev/null +++ b/test/rpg/entity/CMakeLists.txt @@ -0,0 +1,12 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +include(dusktest) + +# Tests +dusktest(test_entitydir.c) +dusktest(test_entity.c) + +# Subdirs diff --git a/test/rpg/entity/test_entity.c b/test/rpg/entity/test_entity.c new file mode 100644 index 00000000..65482220 --- /dev/null +++ b/test/rpg/entity/test_entity.c @@ -0,0 +1,105 @@ +/** + * 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/entity/entity.h" + +static void testEntitiesReset(void) { + memoryZero(ENTITIES, sizeof(ENTITIES)); +} + +static void testEntityPlace( + const uint8_t index, + const entitytype_t type, + const vec3 position, + const entitydir_t direction +) { + entityInit(&ENTITIES[index], type); + ENTITIES[index].direction = direction; + const vec3 extents = ENTITY_PHYSICS_EXTENTS_DEFAULT; + physicsBodyInit(&ENTITIES[index].body, position, extents); +} + +static void test_entityGetFacingFindsEntityAhead(void **state) { + testEntitiesReset(); + testEntityPlace(0, ENTITY_TYPE_PLAYER, (vec3){ 0.0f, 0.0f, 0.0f }, + ENTITY_DIR_EAST); + testEntityPlace(1, ENTITY_TYPE_NPC, (vec3){ 1.0f, 0.0f, 0.0f }, + ENTITY_DIR_NORTH); + + entity_t *result = entityGetFacing(&ENTITIES[0], ENTITY_INTERACT_RANGE); + assert_ptr_equal(result, &ENTITIES[1]); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityGetFacingIgnoresEntityBehind(void **state) { + testEntitiesReset(); + testEntityPlace(0, ENTITY_TYPE_PLAYER, (vec3){ 0.0f, 0.0f, 0.0f }, + ENTITY_DIR_EAST); + testEntityPlace(1, ENTITY_TYPE_NPC, (vec3){ -2.0f, 0.0f, 0.0f }, + ENTITY_DIR_NORTH); + + entity_t *result = entityGetFacing(&ENTITIES[0], ENTITY_INTERACT_RANGE); + assert_null(result); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityGetFacingIgnoresEntityToSide(void **state) { + testEntitiesReset(); + testEntityPlace(0, ENTITY_TYPE_PLAYER, (vec3){ 0.0f, 0.0f, 0.0f }, + ENTITY_DIR_EAST); + testEntityPlace(1, ENTITY_TYPE_NPC, (vec3){ 0.0f, 3.0f, 0.0f }, + ENTITY_DIR_NORTH); + + entity_t *result = entityGetFacing(&ENTITIES[0], ENTITY_INTERACT_RANGE); + assert_null(result); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityGetFacingReturnsNullWhenNothingInRange(void **state) { + testEntitiesReset(); + testEntityPlace(0, ENTITY_TYPE_PLAYER, (vec3){ 0.0f, 0.0f, 0.0f }, + ENTITY_DIR_EAST); + + entity_t *result = entityGetFacing(&ENTITIES[0], ENTITY_INTERACT_RANGE); + assert_null(result); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityGetFacingPicksNearest(void **state) { + testEntitiesReset(); + testEntityPlace(0, ENTITY_TYPE_PLAYER, (vec3){ 0.0f, 0.0f, 0.0f }, + ENTITY_DIR_EAST); + // Farther candidate placed at the lower array index, to prove the + // result is chosen by distance, not array/insertion order. + testEntityPlace(1, ENTITY_TYPE_NPC, (vec3){ 1.5f, 0.0f, 0.0f }, + ENTITY_DIR_NORTH); + testEntityPlace(2, ENTITY_TYPE_NPC, (vec3){ 1.0f, 0.0f, 0.0f }, + ENTITY_DIR_NORTH); + + entity_t *result = entityGetFacing(&ENTITIES[0], ENTITY_INTERACT_RANGE); + assert_ptr_equal(result, &ENTITIES[2]); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +int main(void) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_entityGetFacingFindsEntityAhead), + cmocka_unit_test(test_entityGetFacingIgnoresEntityBehind), + cmocka_unit_test(test_entityGetFacingIgnoresEntityToSide), + cmocka_unit_test(test_entityGetFacingReturnsNullWhenNothingInRange), + cmocka_unit_test(test_entityGetFacingPicksNearest), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/rpg/entity/test_entitydir.c b/test/rpg/entity/test_entitydir.c new file mode 100644 index 00000000..3e2dba88 --- /dev/null +++ b/test/rpg/entity/test_entitydir.c @@ -0,0 +1,84 @@ +/** + * 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/entity/entitydir.h" + +static void test_entityDirToVec2(void **state) { + vec2 out; + + entityDirToVec2(ENTITY_DIR_NORTH, out); + assert_float_equal(out[0], 0.0f, 0.0001f); + assert_float_equal(out[1], 1.0f, 0.0001f); + + entityDirToVec2(ENTITY_DIR_EAST, out); + assert_float_equal(out[0], 1.0f, 0.0001f); + assert_float_equal(out[1], 0.0f, 0.0001f); + + entityDirToVec2(ENTITY_DIR_SOUTH, out); + assert_float_equal(out[0], 0.0f, 0.0001f); + assert_float_equal(out[1], -1.0f, 0.0001f); + + entityDirToVec2(ENTITY_DIR_WEST, out); + assert_float_equal(out[0], -1.0f, 0.0001f); + assert_float_equal(out[1], 0.0f, 0.0001f); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityDirFromVec2Cardinal(void **state) { + assert_int_equal( + entityDirFromVec2((vec2){ 0.0f, 1.0f }), ENTITY_DIR_NORTH + ); + assert_int_equal( + entityDirFromVec2((vec2){ 1.0f, 0.0f }), ENTITY_DIR_EAST + ); + assert_int_equal( + entityDirFromVec2((vec2){ 0.0f, -1.0f }), ENTITY_DIR_SOUTH + ); + assert_int_equal( + entityDirFromVec2((vec2){ -1.0f, 0.0f }), ENTITY_DIR_WEST + ); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityDirFromVec2DominantAxis(void **state) { + // |x| > |y| picks east/west regardless of y's sign. + assert_int_equal( + entityDirFromVec2((vec2){ 2.0f, 1.0f }), ENTITY_DIR_EAST + ); + assert_int_equal( + entityDirFromVec2((vec2){ -2.0f, -1.0f }), ENTITY_DIR_WEST + ); + + // |y| >= |x| picks north/south. + assert_int_equal( + entityDirFromVec2((vec2){ 1.0f, 2.0f }), ENTITY_DIR_NORTH + ); + assert_int_equal( + entityDirFromVec2((vec2){ -1.0f, -3.0f }), ENTITY_DIR_SOUTH + ); + + // Exact diagonal ties break toward north/south. + assert_int_equal( + entityDirFromVec2((vec2){ 0.7f, 0.7f }), ENTITY_DIR_NORTH + ); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +int main(void) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_entityDirToVec2), + cmocka_unit_test(test_entityDirFromVec2Cardinal), + cmocka_unit_test(test_entityDirFromVec2DominantAxis), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/rpg/overworld/CMakeLists.txt b/test/rpg/overworld/CMakeLists.txt index a888f729..7f8ecc94 100644 --- a/test/rpg/overworld/CMakeLists.txt +++ b/test/rpg/overworld/CMakeLists.txt @@ -6,5 +6,6 @@ include(dusktest) # Tests +dusktest(test_maparea.c) # Subdirs \ No newline at end of file diff --git a/test/rpg/overworld/test_maparea.c b/test/rpg/overworld/test_maparea.c new file mode 100644 index 00000000..4e22f963 --- /dev/null +++ b/test/rpg/overworld/test_maparea.c @@ -0,0 +1,126 @@ +/** + * 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/maparea.h" + +static uint32_t TEST_ENTER_COUNT; +static uint32_t TEST_STEP_COUNT; +static uint32_t TEST_EXIT_COUNT; + +static void testAreaCallback(entity_t *entity, const uint8_t trigger) { + if(trigger == MAP_TRIGGER_ENTER) TEST_ENTER_COUNT++; + else if(trigger == MAP_TRIGGER_STEP) TEST_STEP_COUNT++; + else if(trigger == MAP_TRIGGER_EXIT) TEST_EXIT_COUNT++; +} + +static void testMapAreaReset(void) { + memoryZero(MAP_AREAS, sizeof(MAP_AREAS)); + TEST_ENTER_COUNT = 0; + TEST_STEP_COUNT = 0; + TEST_EXIT_COUNT = 0; +} + +static entity_t testEntityAt(const entitytype_t type, const worldpos_t pos) { + entity_t entity; + memoryZero(&entity, sizeof(entity)); + entity.id = 0; + entity.type = type; + entity.position = pos; + return entity; +} + +static void test_mapAreaCheckEntityFiresEnterAndExit(void **state) { + testMapAreaReset(); + mapAreaAdd( + (worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 }, + testAreaCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL + ); + + entity_t entity = testEntityAt(ENTITY_TYPE_PLAYER, (worldpos_t){ 10, 10, 0 }); + mapAreaCheckEntity(&entity); + assert_int_equal(TEST_ENTER_COUNT, 0); + + entity.position = (worldpos_t){ 2, 2, 0 }; + mapAreaCheckEntity(&entity); + assert_int_equal(TEST_ENTER_COUNT, 1); + assert_int_equal(TEST_STEP_COUNT, 0); + + entity.position = (worldpos_t){ 10, 10, 0 }; + mapAreaCheckEntity(&entity); + assert_int_equal(TEST_EXIT_COUNT, 1); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_mapAreaCheckEntityStepFiresOncePerNewTile(void **state) { + testMapAreaReset(); + mapAreaAdd( + (worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 }, + testAreaCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL + ); + + entity_t entity = testEntityAt(ENTITY_TYPE_PLAYER, (worldpos_t){ 1, 1, 0 }); + mapAreaCheckEntity(&entity); + assert_int_equal(TEST_ENTER_COUNT, 1); + assert_int_equal(TEST_STEP_COUNT, 0); + + // Same tile, checked repeatedly (simulating multiple frames without the + // entity moving) - STEP must not fire. + mapAreaCheckEntity(&entity); + mapAreaCheckEntity(&entity); + assert_int_equal(TEST_STEP_COUNT, 0); + + // Moves to a new tile, still inside - STEP fires once. + entity.position = (worldpos_t){ 2, 1, 0 }; + mapAreaCheckEntity(&entity); + assert_int_equal(TEST_STEP_COUNT, 1); + + // Same new tile again over multiple calls - no additional STEP. + mapAreaCheckEntity(&entity); + mapAreaCheckEntity(&entity); + assert_int_equal(TEST_STEP_COUNT, 1); + + // Another new tile - STEP fires again. + entity.position = (worldpos_t){ 3, 1, 0 }; + mapAreaCheckEntity(&entity); + assert_int_equal(TEST_STEP_COUNT, 2); + + assert_int_equal(TEST_ENTER_COUNT, 1); + assert_int_equal(TEST_EXIT_COUNT, 0); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_mapAreaCheckEntityNotifyFlagsRespected(void **state) { + testMapAreaReset(); + mapAreaAdd( + (worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 }, + testAreaCallback, MAP_AREA_NOTIFY_PLAYER, MAP_TRIGGER_ALL + ); + + entity_t npc = testEntityAt(ENTITY_TYPE_NPC, (worldpos_t){ 1, 1, 0 }); + mapAreaCheckEntity(&npc); + assert_int_equal(TEST_ENTER_COUNT, 0); + + entity_t player = testEntityAt(ENTITY_TYPE_PLAYER, (worldpos_t){ 1, 1, 0 }); + mapAreaCheckEntity(&player); + assert_int_equal(TEST_ENTER_COUNT, 1); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +int main(void) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_mapAreaCheckEntityFiresEnterAndExit), + cmocka_unit_test(test_mapAreaCheckEntityStepFiresOncePerNewTile), + cmocka_unit_test(test_mapAreaCheckEntityNotifyFlagsRespected), + }; + + 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 453e1e58..aca90dfb 100644 --- a/test/rpg/physics/test_physicsworld.c +++ b/test/rpg/physics/test_physicsworld.c @@ -61,7 +61,7 @@ static void test_physicsWorldStepStraightLineNoObstacles(void **state) { const uint32_t steps = 10; for(uint32_t i = 0; i < steps; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP); + physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); } assert_float_equal(body.position[0], 1.0f * steps * DUSK_TIME_STEP, 0.001f); @@ -89,7 +89,7 @@ static void test_physicsWorldStepBlockedHorizontally(void **state) { body.velocity[0] = 5.0f; for(uint32_t i = 0; i < 60; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP); + physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); } assert_float_equal(body.position[0], 2.0f, 0.0001f); @@ -97,7 +97,7 @@ static void test_physicsWorldStepBlockedHorizontally(void **state) { // Further steps must not push it past the wall. for(uint32_t i = 0; i < 5; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP); + physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); assert_float_equal(body.position[0], 2.0f, 0.0001f); } @@ -117,7 +117,7 @@ static void test_physicsWorldStepGravitySettlesOnFloor(void **state) { physicsBodyInit(&body, position, extents); for(uint32_t i = 0; i < 200; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP); + physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); assert_true(body.position[2] >= -0.0001f); } @@ -142,7 +142,7 @@ static void test_physicsWorldStepFallsThroughHole(void **state) { for(uint32_t i = 0; i < 50; i++) { const float_t previousZ = body.position[2]; - physicsWorldStep(&world, &body, DUSK_TIME_STEP); + physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); assert_true(body.position[2] < previousZ); assert_false(body.grounded); } @@ -163,7 +163,7 @@ static void test_physicsWorldStepTerminalVelocityClamp(void **state) { physicsBodyInit(&body, position, extents); for(uint32_t i = 0; i < 300; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP); + physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); assert_true(fabsf(body.velocity[2]) <= world.terminalVelocity + 0.0001f); } @@ -186,7 +186,7 @@ static void test_physicsWorldStepBlockedByCeiling(void **state) { body.velocity[2] = 1.0f; for(uint32_t i = 0; i < 200; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP); + physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); } assert_float_equal(body.position[2], 1.0f, 0.0001f); @@ -215,7 +215,7 @@ static void test_physicsWorldStepMultiColumnFootprint(void **state) { body.velocity[0] = 5.0f; for(uint32_t i = 0; i < 60; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP); + physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); } assert_float_equal(body.position[0], 1.0f, 0.0001f); @@ -242,7 +242,7 @@ static void test_physicsWorldStepRampTreatedAsFlatGround(void **state) { body.velocity[0] = 5.0f; for(uint32_t i = 0; i < 60; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP); + physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); assert_float_equal(body.position[2], 0.0f, 0.0001f); } @@ -252,6 +252,91 @@ static void test_physicsWorldStepRampTreatedAsFlatGround(void **state) { assert_int_equal(memoryGetAllocatedCount(), 0); } +static void test_physicsWorldStepBlockedByOtherBody(void **state) { + testMapReset(); + for(worldunit_t x = 0; x < 10; x++) { + testMapSetTile(x, 0, 0, TILE_SHAPE_GROUND); + } + + physicsworld_t world; + physicsWorldInit(&world, 0.0f, 40.0f); + + const vec3 extents = { 1.0f, 1.0f, 1.0f }; + + physicsbody_t other; + physicsBodyInit(&other, (vec3){ 5.0f, 0.0f, 0.0f }, extents); + + physicsbody_t body; + physicsBodyInit(&body, (vec3){ 0.0f, 0.0f, 0.0f }, extents); + body.velocity[0] = 5.0f; + + physicsbody_t *others[] = { &other }; + + for(uint32_t i = 0; i < 60; i++) { + physicsWorldStep(&world, &body, DUSK_TIME_STEP, others, 1); + } + + // Other occupies [5,6) - body (1 unit wide) should stop exactly + // touching it, at x=4. + assert_float_equal(body.position[0], 4.0f, 0.0001f); + assert_float_equal(body.velocity[0], 0.0f, 0.0001f); + assert_float_equal(other.position[0], 5.0f, 0.0001f); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_physicsWorldStepBlockedByNearestOfMultipleBodies( + void **state +) { + testMapReset(); + for(worldunit_t x = 0; x < 20; x++) { + testMapSetTile(x, 0, 0, TILE_SHAPE_GROUND); + } + + physicsworld_t world; + physicsWorldInit(&world, 0.0f, 40.0f); + + const vec3 extents = { 1.0f, 1.0f, 1.0f }; + + physicsbody_t nearOther; + physicsBodyInit(&nearOther, (vec3){ 5.0f, 0.0f, 0.0f }, extents); + + physicsbody_t farOther; + physicsBodyInit(&farOther, (vec3){ 15.0f, 0.0f, 0.0f }, extents); + + physicsbody_t body; + physicsBodyInit(&body, (vec3){ 0.0f, 0.0f, 0.0f }, extents); + body.velocity[0] = 5.0f; + + physicsbody_t *others[] = { &farOther, &nearOther }; + + for(uint32_t i = 0; i < 60; i++) { + physicsWorldStep(&world, &body, DUSK_TIME_STEP, others, 2); + } + + assert_float_equal(body.position[0], 4.0f, 0.0001f); + assert_float_equal(body.velocity[0], 0.0f, 0.0001f); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_physicsWorldResolveBodyOverlapIgnoresSelfAndNull( + void **state +) { + const vec3 extents = { 1.0f, 1.0f, 1.0f }; + physicsbody_t body; + physicsBodyInit(&body, (vec3){ 0.0f, 0.0f, 0.0f }, extents); + body.velocity[0] = 1.0f; + + physicsbody_t *others[] = { &body, NULL }; + physicsWorldResolveBodyOverlap(&body, 0, others, 2); + + assert_float_equal(body.position[0], 0.0f, 0.0001f); + assert_float_equal(body.velocity[0], 1.0f, 0.0001f); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + int main(void) { const struct CMUnitTest tests[] = { cmocka_unit_test(test_physicsWorldStepStraightLineNoObstacles), @@ -262,6 +347,9 @@ int main(void) { cmocka_unit_test(test_physicsWorldStepBlockedByCeiling), cmocka_unit_test(test_physicsWorldStepMultiColumnFootprint), cmocka_unit_test(test_physicsWorldStepRampTreatedAsFlatGround), + cmocka_unit_test(test_physicsWorldStepBlockedByOtherBody), + cmocka_unit_test(test_physicsWorldStepBlockedByNearestOfMultipleBodies), + cmocka_unit_test(test_physicsWorldResolveBodyOverlapIgnoresSelfAndNull), }; return cmocka_run_group_tests(tests, NULL, NULL);