Call collide from the simulation, after the move rather than before it
The collide slot has existed unused since the backend was written. It is wired in now, and its signature changes from (self, a1, a2) to (self, actor, dt): the old shape had nowhere to put a normal, a depth, or a piece of map geometry, and no answer to which of the two actors it was supposed to be resolving. **Resolution runs after `move`.** That is the decision everything else follows from. A resolver called from movementlogicfunc runs before gravity, drag and the move, so it has to predict where the actor will end up -- which means re-implementing the integrator's arithmetic in the game, and being wrong whenever the integrator changes. examples/sidescroller/collision.c carries forty lines of exactly that, and says so. Only `move` is subdivided. Gravity, drag, the thrust integration and the speed ellipse all still run once against the whole dt, and sum(subdt) is dt, so an actor with nothing to collide with takes one sub-step of exactly dt -- `dt/1.0f` is dt exactly in IEEE-754 -- and follows the arithmetic path it always did. That is what lets the integrator stay untouched and every recorded physics number stay valid, and tests/physics.c and tests/physics_sim.c pass unchanged with no world attached. Collision is opt-in. A backend with no collision world costs one comparison per actor per step and does nothing else. Also here: - `self->gravity` was never null-checked and is dereferenced unconditionally, so a hand-built backend with only `move` filled in crashed rather than reporting. - Children are re-snapped in a post-pass, gated on collision being on. The in-loop snap uses whatever position the parent had when the child's slot came up in pool order, which is already sometimes a frame stale; that was harmless while a parent only moved by v*dt and is not once a parent can be pushed out of a wall mid-step. - tests/physics.c's deliberate tripwire has been visited. It asserted that arcade collide always raised AKERR_API, with a comment saying it existed so that implementing collision would have to come back here. What replaces it asserts the opt-in contract instead. - physics_sim.c's hard-coded "%d of 7 simulations" is derived now. A literal goes stale the first time somebody adds a simulation, which is this commit. Three new whole-motion simulations, and the third took two attempts to make honest: - Landing and resting: 0.0000 px of drift over 120 steps after settling. - Walking after landing: 107 px in a second, which is the symptom a player sees when a resting actor is caught on the ground it is standing on. - A fast fall not tunnelling. The first version used gravity, so the step size grew every frame and the actor happened to land *inside* the floor rather than stepping over it -- it passed with sub-stepping disabled and proved nothing. It uses a constant 1200 px/s now, so every step is exactly 60 pixels against a 32-pixel window in which an overlap exists, and the samples miss it cleanly. With sub-stepping the actor stops at y=288; without it, y=1300. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -454,6 +454,61 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_query_box(akgl_CollisionWorld
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_settle(akgl_CollisionWorld *self, akgl_CollisionShape *shape, float32_t *x, float32_t *y, int maxsteps);
|
||||
|
||||
/**
|
||||
* @brief Resolve one actor against everything it currently overlaps.
|
||||
*
|
||||
* Called by the physics step after each sub-move, so it looks at where the actor
|
||||
* *is* rather than predicting where it will be. That is what lets a game delete
|
||||
* the arithmetic it would otherwise have to duplicate from the integrator.
|
||||
*
|
||||
* Tiles first, then proxies. Each contact goes to the actor's `collidefunc`, and
|
||||
* the index entry is re-synced after every response, because a response that
|
||||
* moves the actor invalidates everything computed before it.
|
||||
*
|
||||
* @param self The world. Required.
|
||||
* @param actor The actor. Required. Returns success unchanged if it has no
|
||||
* shape, no proxy or no response hook.
|
||||
* @param dt Length of the sub-step, in seconds. Carried on each contact.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or @p actor is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_resolve(akgl_CollisionWorld *self, struct akgl_Actor *actor, float32_t dt);
|
||||
|
||||
/**
|
||||
* @brief How many pieces this actor's move has to be taken in.
|
||||
*
|
||||
* Bounds how far an actor travels between collision tests to less than the
|
||||
* thinnest thing it could pass through -- a cell, or its own extent, whichever
|
||||
* is smaller, because a small fast actor is the one that tunnels.
|
||||
*
|
||||
* Answers 1 when there is no world or the actor has no shape, and the caller
|
||||
* then takes exactly the step it always did: `dt / 1.0f` is `dt` exactly, so a
|
||||
* non-colliding actor follows a bit-identical arithmetic path.
|
||||
*
|
||||
* @param self The world, or `NULL` for no collision.
|
||||
* @param actor The actor. Required.
|
||||
* @param dt Length of the whole step, in seconds.
|
||||
* @param dest Receives the count, never below 1. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p actor or @p dest is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_substeps(akgl_CollisionWorld *self, struct akgl_Actor *actor, float32_t dt, int *dest);
|
||||
|
||||
/**
|
||||
* @brief Bring every live actor's proxy into agreement with the actor.
|
||||
*
|
||||
* One pass over the actor pool at the top of a step: create a proxy for anything
|
||||
* that has gained a shape, release one from anything that has lost its shape,
|
||||
* and refresh the rest. The only place a proxy is created or destroyed during a
|
||||
* step, so nothing downstream has to reason about lifetimes.
|
||||
*
|
||||
* @param self The world. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
||||
* @throws AKGL_ERR_HEAP If the proxy pool is exhausted.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_sync_actors(akgl_CollisionWorld *self);
|
||||
|
||||
/*
|
||||
* The following is part of the internal API. Proxies are created and destroyed
|
||||
* by the library, not by a game.
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/collision.h>
|
||||
#include <akgl/iterator.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
@@ -52,7 +53,7 @@
|
||||
typedef struct akgl_PhysicsBackend {
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*simulate)(struct akgl_PhysicsBackend *self, akgl_Iterator *opflags); /**< Step the whole world. Both backends point this at akgl_physics_simulate. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*gravity)(struct akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt); /**< Apply environmental acceleration to one actor. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*collide)(struct akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2); /**< Resolve a collision between two actors. Not called by the simulation loop yet. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*collide)(struct akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt); /**< Resolve one actor against this backend's collision world for one sub-step. Called by akgl_physics_simulate after every sub-move. A `NULL` slot, or a `NULL` #collision, means nothing collides. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*move)(struct akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt); /**< Commit one actor's velocity to its position. */
|
||||
|
||||
float64_t drag_x; /**< Fraction of environmental velocity shed per second along x. 0 disables. From `physics.drag.x`. */
|
||||
@@ -62,6 +63,7 @@ typedef struct akgl_PhysicsBackend {
|
||||
float64_t gravity_y; /**< Acceleration along y. Applied down the screen, since y grows downward. From `physics.gravity.y`. */
|
||||
float64_t gravity_z; /**< Acceleration along z. Applied away from the camera. From `physics.gravity.z`. */
|
||||
SDL_Time gravity_time; /**< Timestamp of the previous step, in nanoseconds. Stamped by akgl_physics_simulate and seeded by the initializers; the simulation's `dt` is measured from this. */
|
||||
struct akgl_CollisionWorld *collision; /**< The collision world this backend resolves against, or `NULL` for none. A backend with no world behaves exactly as one did before collision existed, which is what makes collision opt-in and what keeps the recorded physics baseline honest. */
|
||||
float64_t max_timestep; /**< Longest step the simulation will take, in seconds. A hitch longer than this advances the world by this much instead. 0 disables the bound. From `physics.max_timestep`, default #AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP. */
|
||||
} akgl_PhysicsBackend;
|
||||
|
||||
@@ -81,13 +83,15 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_null_gravity(akgl_PhysicsBackend
|
||||
* Note that this *succeeds* where the arcade backend's collide refuses -- the
|
||||
* null backend's contract is "nothing collides", which is an answer, not a gap.
|
||||
*
|
||||
* @param self The backend. Required.
|
||||
* @param a1 First actor. Ignored; `NULL` is accepted.
|
||||
* @param a2 Second actor. Ignored; `NULL` is accepted.
|
||||
* @param self The backend. Required.
|
||||
* @param actor The actor that would have been resolved. Required, and checked,
|
||||
* because a caller passing rubbish should find out regardless of
|
||||
* which backend happens to be installed.
|
||||
* @param dt Length of the sub-step, in seconds. Ignored.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
||||
* @throws AKERR_NULLPOINTER If @p self or @p actor is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_null_collide(akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_null_collide(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt);
|
||||
/**
|
||||
* @brief Movement for the null backend: accept the call and change nothing.
|
||||
*
|
||||
@@ -133,20 +137,25 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_init_null(akgl_PhysicsBackend *s
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_arcade_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt);
|
||||
/**
|
||||
* @brief Collision for the arcade backend. Not implemented.
|
||||
* @brief Collision for the arcade backend: resolve one actor against the world.
|
||||
*
|
||||
* Deliberately loud rather than silently permissive: unlike
|
||||
* akgl_physics_null_collide, which means "nothing collides", this means "nobody
|
||||
* has written this yet", and a caller that reaches it should find out.
|
||||
* Called by akgl_physics_simulate after each sub-move, so it resolves against
|
||||
* where the actor is rather than where it was predicted to be.
|
||||
*
|
||||
* @param self The backend. Required, and checked before the refusal.
|
||||
* @param a1 First actor. Never examined.
|
||||
* @param a2 Second actor. Never examined.
|
||||
* @return Never `NULL`.
|
||||
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
||||
* @throws AKERR_API Otherwise, always, with the message "Not implemented".
|
||||
* **A backend with no #akgl_PhysicsBackend::collision returns success having
|
||||
* changed nothing.** That is what makes collision opt-in: a program written
|
||||
* before any of this existed installs a backend, never attaches a world, and
|
||||
* runs exactly as it did.
|
||||
*
|
||||
* @param self The backend. Required.
|
||||
* @param actor The actor to resolve. Required.
|
||||
* @param dt Length of the sub-step, in seconds. Carried on each contact so a
|
||||
* response can scale with it.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or @p actor is `NULL`.
|
||||
* @throws AKGL_ERR_COLLISION If a narrowphase query could not be answered.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_arcade_collide(akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_arcade_collide(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt);
|
||||
/**
|
||||
* @brief Commit an actor's velocity to its position.
|
||||
*
|
||||
|
||||
310
src/collision.c
310
src/collision.c
@@ -17,6 +17,8 @@
|
||||
#include <akstdlib.h>
|
||||
#include <akgl/collision_arena.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/types.h>
|
||||
|
||||
@@ -56,6 +58,24 @@
|
||||
/** @brief Tiles akgl_collision_settle will lift a shape before giving up. */
|
||||
#define AKGL_COLLISION_SETTLE_STEPS 4
|
||||
|
||||
/**
|
||||
* @brief Fraction of the thinnest passable thickness one sub-step may cover.
|
||||
*
|
||||
* Half. Anything that moves less than half the thinnest thing it could pass
|
||||
* through cannot be on both sides of it between two samples.
|
||||
*/
|
||||
#define AKGL_COLLISION_SUBSTEP_FRACTION 0.5f
|
||||
|
||||
/**
|
||||
* @brief Hard ceiling on sub-steps per actor per step.
|
||||
*
|
||||
* A cost bound. Above roughly `MAX_SUBSTEPS * FRACTION * cellsize` per step an
|
||||
* actor can still pass through a wall -- about 1280 px/s on 16-pixel tiles at
|
||||
* the default step, which is a projectile and not a walker. The real answer is
|
||||
* a swept narrowphase; #AKGL_COLLISION_FLAG_BULLET reserves the bit for it.
|
||||
*/
|
||||
#define AKGL_COLLISION_MAX_SUBSTEPS 8
|
||||
|
||||
/** @brief One positioned shape, in the form libccd's callbacks read. */
|
||||
typedef struct {
|
||||
uint8_t kind; /**< AKGL_COLLISION_SHAPE_*. */
|
||||
@@ -675,3 +695,293 @@ akerr_ErrorContext *akgl_collision_settle(akgl_CollisionWorld *self, akgl_Collis
|
||||
maxsteps
|
||||
);
|
||||
}
|
||||
|
||||
/** @brief Carries one actor's resolution through the broad-phase visitor. */
|
||||
typedef struct {
|
||||
akgl_CollisionWorld *world;
|
||||
akgl_Actor *actor;
|
||||
akgl_CollisionProxy *proxy;
|
||||
float32_t dt;
|
||||
akerr_ErrorContext *failure;
|
||||
} collision_resolve_state;
|
||||
|
||||
/**
|
||||
* @brief Test one candidate against the actor being resolved, and answer it.
|
||||
*
|
||||
* The visitor cannot use CATCH: a `break` here would leave the broad phase's own
|
||||
* walk rather than this function, so a failure would look like the end of the
|
||||
* list. The status is carried out on the state instead and handed over by the
|
||||
* caller.
|
||||
*/
|
||||
static akerr_ErrorContext *collision_resolve_visit(akgl_CollisionProxy *other, void *data)
|
||||
{
|
||||
collision_resolve_state *state = (collision_resolve_state *)data;
|
||||
akgl_Contact contact;
|
||||
bool interacts = false;
|
||||
bool hit = false;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, other, AKERR_NULLPOINTER, "NULL proxy in a resolve");
|
||||
FAIL_ZERO_RETURN(errctx, state, AKERR_NULLPOINTER, "NULL resolve state");
|
||||
|
||||
if ( other == state->proxy ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/*
|
||||
* Nested rather than written with early returns. A *_RETURN inside an
|
||||
* ATTEMPT block leaves past CLEANUP, which is a real defect even where the
|
||||
* CLEANUP happens to be empty today -- and scripts/check_error_protocol.py
|
||||
* refuses it, correctly.
|
||||
*/
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_collision_shape_interacts(&state->proxy->shape, &other->shape, &interacts));
|
||||
if ( interacts == true ) {
|
||||
state->world->tests += 1;
|
||||
CATCH(errctx, akgl_collision_test(state->proxy, other, state->world->flags, &contact, &hit));
|
||||
if ( hit == true ) {
|
||||
contact.self = state->actor;
|
||||
contact.other = other->owner;
|
||||
contact.dt = state->dt;
|
||||
contact.sensor = (((state->proxy->shape.flags & AKGL_COLLISION_FLAG_SENSOR) != 0) ||
|
||||
((other->shape.flags & AKGL_COLLISION_FLAG_SENSOR) != 0));
|
||||
contact.statichit = ((other->shape.flags & AKGL_COLLISION_FLAG_STATIC) != 0);
|
||||
|
||||
CATCH(errctx, state->actor->collidefunc(state->actor, &contact));
|
||||
|
||||
// The actor moved, so its own proxy is stale for the rest of the pass.
|
||||
CATCH(errctx, akgl_collision_proxy_sync(state->proxy, &state->actor->shape,
|
||||
state->actor->x, state->actor->y,
|
||||
state->actor->z));
|
||||
}
|
||||
}
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Resolve one actor against the tiles its shape overlaps.
|
||||
*
|
||||
* Tiles are not proxies, so they are scanned rather than queried: a stack-local
|
||||
* proxy is synthesized for each solid cell and handed to the ordinary
|
||||
* narrowphase, which keeps one code path for "what does a contact look like"
|
||||
* rather than two.
|
||||
*/
|
||||
static akerr_ErrorContext *collision_resolve_tiles(akgl_CollisionWorld *self, akgl_Actor *actor,
|
||||
akgl_CollisionProxy *proxy, float32_t dt)
|
||||
{
|
||||
akgl_CollisionProxy tile;
|
||||
akgl_CollisionShape tileshape;
|
||||
akgl_Contact contact;
|
||||
SDL_FRect body;
|
||||
bool hit = false;
|
||||
int32_t tx = 0;
|
||||
int32_t ty = 0;
|
||||
int32_t x0 = 0;
|
||||
int32_t y0 = 0;
|
||||
int32_t x1 = 0;
|
||||
int32_t y1 = 0;
|
||||
int layer = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
if ( (self->tilesource == NULL) || (self->tilelayers == 0) ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
if ( (proxy->shape.collidemask & self->tilelayermask) == 0 ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
x0 = (int32_t)floorf(proxy->bounds.x / (float32_t)self->tilesource->tilewidth);
|
||||
y0 = (int32_t)floorf(proxy->bounds.y / (float32_t)self->tilesource->tileheight);
|
||||
x1 = (int32_t)floorf(((proxy->bounds.x + proxy->bounds.w) - AKGL_COLLISION_TILE_EPSILON) /
|
||||
(float32_t)self->tilesource->tilewidth);
|
||||
y1 = (int32_t)floorf(((proxy->bounds.y + proxy->bounds.h) - AKGL_COLLISION_TILE_EPSILON) /
|
||||
(float32_t)self->tilesource->tileheight);
|
||||
|
||||
body.w = (float32_t)self->tilesource->tilewidth;
|
||||
body.h = (float32_t)self->tilesource->tileheight;
|
||||
body.x = 0.0f;
|
||||
body.y = 0.0f;
|
||||
|
||||
for ( ty = y0; ty <= y1; ty++ ) {
|
||||
for ( tx = x0; tx <= x1; tx++ ) {
|
||||
if ( !collision_tile_solid(self, tx, ty) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_collision_shape_box(&tileshape, &body, 0.0f));
|
||||
tileshape.layermask = self->tilelayermask;
|
||||
tileshape.flags |= AKGL_COLLISION_FLAG_STATIC;
|
||||
CATCH(errctx, akgl_collision_proxy_initialize(
|
||||
&tile, NULL, &tileshape,
|
||||
(float32_t)(tx * self->tilesource->tilewidth),
|
||||
(float32_t)(ty * self->tilesource->tileheight), proxy->z));
|
||||
|
||||
CATCH(errctx, akgl_collision_test(proxy, &tile, self->flags, &contact, &hit));
|
||||
self->tests += 1;
|
||||
if ( hit == true ) {
|
||||
contact.self = actor;
|
||||
contact.other = NULL;
|
||||
contact.dt = dt;
|
||||
contact.statichit = true;
|
||||
contact.tilex = tx;
|
||||
contact.tiley = ty;
|
||||
contact.tilegid = 0;
|
||||
contact.tilelayer = -1;
|
||||
// Which layer and which tile, so a game can tell a spike from
|
||||
// a floor without looking it up again.
|
||||
for ( layer = 0; layer < self->tilesource->numlayers; layer++ ) {
|
||||
if ( (self->tilelayers & (1u << layer)) == 0 ) {
|
||||
continue;
|
||||
}
|
||||
if ( self->tilesource->layers[layer].data[(ty * self->tilesource->width) + tx] != 0 ) {
|
||||
contact.tilelayer = layer;
|
||||
contact.tilegid = self->tilesource->layers[layer].data[(ty * self->tilesource->width) + tx];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
CATCH(errctx, actor->collidefunc(actor, &contact));
|
||||
CATCH(errctx, akgl_collision_proxy_sync(proxy, &actor->shape,
|
||||
actor->x, actor->y, actor->z));
|
||||
}
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_collision_resolve(akgl_CollisionWorld *self, akgl_Actor *actor, float32_t dt)
|
||||
{
|
||||
collision_resolve_state state;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL collision world reference");
|
||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "NULL actor reference");
|
||||
|
||||
if ( (actor->proxy == NULL) || (actor->shape.kind == AKGL_COLLISION_SHAPE_NONE) ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
if ( actor->collidefunc == NULL ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
// The actor has just moved, so the index and the copy of its shape are both
|
||||
// a step out of date.
|
||||
PASS(errctx, akgl_collision_proxy_sync(actor->proxy, &actor->shape, actor->x, actor->y, actor->z));
|
||||
PASS(errctx, self->partitioner.move(&self->partitioner, actor->proxy));
|
||||
|
||||
PASS(errctx, collision_resolve_tiles(self, actor, actor->proxy, dt));
|
||||
|
||||
memset(&state, 0x00, sizeof(state));
|
||||
state.world = self;
|
||||
state.actor = actor;
|
||||
state.proxy = actor->proxy;
|
||||
state.dt = dt;
|
||||
PASS(errctx, self->partitioner.query(&self->partitioner, &actor->proxy->bounds,
|
||||
actor->shape.collidemask, &collision_resolve_visit, &state));
|
||||
|
||||
// Whatever the responses did, the index has to end the pass agreeing with
|
||||
// where the actor actually is.
|
||||
PASS(errctx, akgl_collision_proxy_sync(actor->proxy, &actor->shape, actor->x, actor->y, actor->z));
|
||||
PASS(errctx, self->partitioner.move(&self->partitioner, actor->proxy));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_collision_substeps(akgl_CollisionWorld *self, akgl_Actor *actor, float32_t dt, int *dest)
|
||||
{
|
||||
float32_t span = 0.0f;
|
||||
float32_t limit = 0.0f;
|
||||
float32_t thin = 0.0f;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "NULL actor reference");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination reference");
|
||||
|
||||
/*
|
||||
* One, unless there is something to collide with. A step of exactly `dt`
|
||||
* takes the same arithmetic path it always has -- `dt / 1.0f` is `dt`
|
||||
* exactly in IEEE-754 -- which is what lets every existing assertion about
|
||||
* a non-colliding actor hold unchanged.
|
||||
*/
|
||||
*dest = 1;
|
||||
if ( (self == NULL) || (actor->shape.kind == AKGL_COLLISION_SHAPE_NONE) ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
span = fabsf(actor->vx);
|
||||
if ( fabsf(actor->vy) > span ) { span = fabsf(actor->vy); }
|
||||
if ( fabsf(actor->vz) > span ) { span = fabsf(actor->vz); }
|
||||
span = span * dt;
|
||||
|
||||
/*
|
||||
* Do not travel further in one sub-step than the thinnest thing that could
|
||||
* be passed through. That is a cell *or the actor's own extent*, whichever
|
||||
* is smaller: a small fast actor is the one that tunnels, and bounding only
|
||||
* by cell size misses it entirely.
|
||||
*/
|
||||
thin = self->cellwidth;
|
||||
if ( self->cellheight < thin ) { thin = self->cellheight; }
|
||||
if ( (2.0f * actor->shape.hx) < thin ) { thin = 2.0f * actor->shape.hx; }
|
||||
if ( (2.0f * actor->shape.hy) < thin ) { thin = 2.0f * actor->shape.hy; }
|
||||
limit = AKGL_COLLISION_SUBSTEP_FRACTION * thin;
|
||||
|
||||
if ( limit <= 0.0f ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
*dest = (int)(span / limit) + 1;
|
||||
if ( *dest > AKGL_COLLISION_MAX_SUBSTEPS ) {
|
||||
*dest = AKGL_COLLISION_MAX_SUBSTEPS;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_collision_sync_actors(akgl_CollisionWorld *self)
|
||||
{
|
||||
akgl_Actor *actor = NULL;
|
||||
int i = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL collision world reference");
|
||||
|
||||
/*
|
||||
* One pass over the pool: give a proxy to anything that has gained a shape,
|
||||
* take one back from anything that has lost one, and refresh the rest. This
|
||||
* is the only place a proxy is created or destroyed during a step, so
|
||||
* nothing below has to think about lifetimes.
|
||||
*/
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
actor = &akgl_heap_actors[i];
|
||||
if ( actor->refcount == 0 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( actor->shape.kind == AKGL_COLLISION_SHAPE_NONE ) {
|
||||
if ( actor->proxy != NULL ) {
|
||||
PASS(errctx, self->partitioner.remove(&self->partitioner, actor->proxy));
|
||||
PASS(errctx, akgl_heap_release_collision_proxy(actor->proxy));
|
||||
actor->proxy = NULL;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( actor->proxy == NULL ) {
|
||||
PASS(errctx, akgl_heap_next_collision_proxy(&actor->proxy));
|
||||
PASS(errctx, akgl_collision_proxy_initialize(actor->proxy, actor, &actor->shape,
|
||||
actor->x, actor->y, actor->z));
|
||||
PASS(errctx, self->partitioner.insert(&self->partitioner, actor->proxy));
|
||||
continue;
|
||||
}
|
||||
|
||||
PASS(errctx, akgl_collision_proxy_sync(actor->proxy, &actor->shape,
|
||||
actor->x, actor->y, actor->z));
|
||||
PASS(errctx, self->partitioner.move(&self->partitioner, actor->proxy));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <math.h>
|
||||
#include <akstdlib.h>
|
||||
#include <akgl/collision.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/game.h>
|
||||
@@ -20,12 +21,18 @@ akerr_ErrorContext *akgl_physics_null_gravity(akgl_PhysicsBackend *self, akgl_Ac
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_physics_null_collide(akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2)
|
||||
akerr_ErrorContext *akgl_physics_null_collide(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, a1, AKERR_NULLPOINTER, "a1");
|
||||
FAIL_ZERO_RETURN(errctx, a2, AKERR_NULLPOINTER, "a2");
|
||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
||||
(void)dt;
|
||||
|
||||
/*
|
||||
* Nothing collides, and that is an answer rather than a gap. A caller who
|
||||
* wants a world with no collision in it installs this backend and gets it,
|
||||
* without the simulation having to branch on whether collision exists.
|
||||
*/
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -79,11 +86,19 @@ akerr_ErrorContext *akgl_physics_arcade_gravity(akgl_PhysicsBackend *self, akgl_
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_physics_arcade_collide(akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2)
|
||||
akerr_ErrorContext *akgl_physics_arcade_collide(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_RETURN(errctx, AKERR_API, "Not implemented");
|
||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
||||
|
||||
// No world means no collision, and costs one comparison. This is what makes
|
||||
// the feature opt-in: a backend nobody attached a world to runs exactly as
|
||||
// it did before any of this existed.
|
||||
if ( self->collision == NULL ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
PASS(errctx, akgl_collision_resolve(self->collision, actor, dt));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -152,10 +167,17 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat
|
||||
float32_t dt = 0;
|
||||
float32_t overshoot = 0.0f;
|
||||
float32_t thrustscale = 0.0f;
|
||||
float32_t subdt = 0.0f;
|
||||
int substeps = 1;
|
||||
int s = 0;
|
||||
akgl_Actor *actor = NULL;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->move, AKERR_NULLPOINTER, "self->move");
|
||||
// `gravity` was never checked, and is dereferenced unconditionally below. A
|
||||
// backend built by hand with only `move` filled in crashed here rather than
|
||||
// reporting; `collide` is allowed to be NULL and means "nothing collides".
|
||||
FAIL_ZERO_RETURN(errctx, self->gravity, AKERR_NULLPOINTER, "self->gravity");
|
||||
|
||||
// Reading the elapsed time requires self, so it cannot be hoisted above
|
||||
// the null check.
|
||||
@@ -183,6 +205,12 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat
|
||||
opflags = &defflags;
|
||||
}
|
||||
|
||||
// One pass to give every shaped actor a proxy and take one back from
|
||||
// anything that lost its shape, so the loop below never has to.
|
||||
if ( self->collision != NULL ) {
|
||||
PASS(errctx, akgl_collision_sync_actors(self->collision));
|
||||
}
|
||||
|
||||
|
||||
for ( int i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
actor = &akgl_heap_actors[i];
|
||||
@@ -268,13 +296,49 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat
|
||||
actor->vy = actor->ey + actor->ty;
|
||||
actor->vz = actor->ez + actor->tz;
|
||||
|
||||
PASS(errctx, self->move(self, actor, dt));
|
||||
/*
|
||||
* Only `move` is subdivided. Gravity, drag, the thrust integration
|
||||
* and the speed-ellipse cap above all ran once against the whole dt,
|
||||
* and sum(subdt) is dt -- so an actor with nothing to collide with
|
||||
* takes exactly one sub-step of exactly dt and follows the arithmetic
|
||||
* path it always did. That is what lets the integrator stay untouched
|
||||
* and every recorded physics number stay valid.
|
||||
*/
|
||||
CATCH(errctx, akgl_collision_substeps(self->collision, actor, dt, &substeps));
|
||||
subdt = dt / (float32_t)substeps;
|
||||
for ( s = 0; s < substeps; s++ ) {
|
||||
PASS(errctx, self->move(self, actor, subdt));
|
||||
if ( (self->collide != NULL) && (self->collision != NULL) ) {
|
||||
PASS(errctx, self->collide(self, actor, subdt));
|
||||
}
|
||||
}
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKGL_ERR_LOGICINTERRUPT) {
|
||||
// noop
|
||||
} FINISH(errctx, true);
|
||||
}
|
||||
/*
|
||||
* Re-snap children after everything has moved, but only when collision is
|
||||
* on. The in-loop snap uses whatever position the parent had when the
|
||||
* child's own slot came up in pool order, which is already sometimes a
|
||||
* frame stale -- harmless while a parent only ever moved by `v * dt`, and
|
||||
* not harmless once a parent can also be pushed out of a wall part way
|
||||
* through its own sub-steps. Gating it keeps a collision-free world
|
||||
* byte-for-byte what it was.
|
||||
*/
|
||||
if ( self->collision != NULL ) {
|
||||
for ( int i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
actor = &akgl_heap_actors[i];
|
||||
if ( (actor->refcount == 0) || (actor->parent == NULL) ) {
|
||||
continue;
|
||||
}
|
||||
actor->x = actor->parent->x + actor->vx;
|
||||
actor->y = actor->parent->y + actor->vy;
|
||||
actor->z = actor->parent->z + actor->vz;
|
||||
}
|
||||
}
|
||||
|
||||
self->gravity_time = curtime;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ akerr_ErrorContext *test_physics_null_backend_is_inert(void)
|
||||
|
||||
TEST_EXPECT_OK(e, backend.gravity(&backend, &actor, 1.0f), "null gravity");
|
||||
TEST_EXPECT_OK(e, backend.move(&backend, &actor, 1.0f), "null move");
|
||||
TEST_EXPECT_OK(e, backend.collide(&backend, &actor, &reference), "null collide");
|
||||
TEST_EXPECT_OK(e, backend.collide(&backend, &actor, 1.0f), "null collide");
|
||||
|
||||
TEST_ASSERT(e, memcmp(&actor, &reference, sizeof(akgl_Actor)) == 0,
|
||||
"the null backend modified the actor");
|
||||
@@ -143,7 +143,7 @@ akerr_ErrorContext *test_physics_null_backend_is_inert(void)
|
||||
"null gravity with NULL self");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_move(NULL, &actor, 1.0f),
|
||||
"null move with NULL self");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(NULL, &actor, &reference),
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(NULL, &actor, 1.0f),
|
||||
"null collide with NULL self");
|
||||
|
||||
// The actor arguments too. The arcade backend has always checked these;
|
||||
@@ -153,10 +153,8 @@ akerr_ErrorContext *test_physics_null_backend_is_inert(void)
|
||||
"null gravity with a NULL actor");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_move(&backend, NULL, 1.0f),
|
||||
"null move with a NULL actor");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(&backend, NULL, &reference),
|
||||
"null collide with a NULL first actor");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(&backend, &actor, NULL),
|
||||
"null collide with a NULL second actor");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(&backend, NULL, 1.0f),
|
||||
"null collide with a NULL actor");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
@@ -272,29 +270,50 @@ akerr_ErrorContext *test_physics_arcade_move(void)
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_arcade_collide_unimplemented(void)
|
||||
/**
|
||||
* @brief The arcade backend resolves, and does nothing when there is no world.
|
||||
*
|
||||
* This function used to assert that akgl_physics_arcade_collide always raised
|
||||
* AKERR_API, with a comment saying it existed so that implementing collision
|
||||
* would have to come back here. It has, and this is that visit.
|
||||
*
|
||||
* What replaces it is the opt-in contract: a backend with no collision world
|
||||
* attached returns success having changed nothing, which is what keeps every
|
||||
* program written before collision existed running exactly as it did.
|
||||
*/
|
||||
akerr_ErrorContext *test_physics_arcade_collide(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
akgl_Actor a1;
|
||||
akgl_Actor a2;
|
||||
akgl_Actor actor;
|
||||
akgl_Actor reference;
|
||||
|
||||
ATTEMPT {
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
memset(&a1, 0x00, sizeof(akgl_Actor));
|
||||
memset(&a2, 0x00, sizeof(akgl_Actor));
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.x = 3.0f;
|
||||
actor.y = 5.0f;
|
||||
memcpy(&reference, &actor, sizeof(akgl_Actor));
|
||||
|
||||
// Pins the stub so that implementing collision has to update this test.
|
||||
TEST_EXPECT_STATUS(e, AKERR_API, akgl_physics_arcade_collide(&backend, &a1, &a2),
|
||||
"arcade collide is still a stub");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_collide(NULL, &a1, &a2),
|
||||
// No world: success, and the actor is untouched.
|
||||
TEST_EXPECT_OK(e, akgl_physics_arcade_collide(&backend, &actor, 1.0f),
|
||||
"arcade collide with no collision world");
|
||||
TEST_ASSERT(e, memcmp(&actor, &reference, sizeof(akgl_Actor)) == 0,
|
||||
"arcade collide moved an actor with no collision world attached");
|
||||
|
||||
// Both pointers checked. The old stub returned AKERR_API before looking
|
||||
// at either, so which arguments were validated depended on the backend.
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_collide(NULL, &actor, 1.0f),
|
||||
"arcade collide with NULL self");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_collide(&backend, NULL, 1.0f),
|
||||
"arcade collide with a NULL actor");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
|
||||
akerr_ErrorContext *test_physics_factory(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
@@ -792,7 +811,7 @@ int main(void)
|
||||
CATCH(errctx, test_physics_null_backend_is_inert());
|
||||
CATCH(errctx, test_physics_arcade_gravity());
|
||||
CATCH(errctx, test_physics_arcade_move());
|
||||
CATCH(errctx, test_physics_arcade_collide_unimplemented());
|
||||
CATCH(errctx, test_physics_arcade_collide());
|
||||
CATCH(errctx, test_physics_factory());
|
||||
CATCH(errctx, test_physics_init_arcade_properties());
|
||||
CATCH(errctx, test_physics_simulate_skips_inactive());
|
||||
|
||||
@@ -44,7 +44,9 @@
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/collision.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/sprite.h>
|
||||
|
||||
@@ -606,10 +608,205 @@ static int sim_run(const char *name, akerr_ErrorContext *(*simulation)(void))
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief A floor across the bottom, with a two-tile hole in it.
|
||||
*
|
||||
* Static, not local: sizeof(akgl_Tilemap) is about 26 MB and a local one
|
||||
* overflows the stack before the first assertion runs.
|
||||
*/
|
||||
static akgl_Tilemap sim_map;
|
||||
static akgl_CollisionWorld sim_world;
|
||||
|
||||
static akerr_ErrorContext *sim_collision_world(void)
|
||||
{
|
||||
int x = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
memset(&sim_map, 0x00, sizeof(akgl_Tilemap));
|
||||
sim_map.tilewidth = 16;
|
||||
sim_map.tileheight = 16;
|
||||
sim_map.width = 40;
|
||||
sim_map.height = 20;
|
||||
sim_map.numlayers = 1;
|
||||
sim_map.layers[0].type = AKGL_TILEMAP_LAYER_TYPE_TILES;
|
||||
for ( x = 0; x < sim_map.width; x++ ) {
|
||||
if ( (x == 20) || (x == 21) ) {
|
||||
continue; // a hole to fall through
|
||||
}
|
||||
sim_map.layers[0].data[(19 * sim_map.width) + x] = 1;
|
||||
}
|
||||
sim_map.collidablelayers = 1u;
|
||||
|
||||
PASS(errctx, akgl_collision_world_init(&sim_world, NULL, 16.0f, 16.0f));
|
||||
PASS(errctx, akgl_collision_bind_tilemap(&sim_world, &sim_map));
|
||||
sim_physics.collision = &sim_world;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief An actor dropped on a floor lands on it and stays there.
|
||||
*
|
||||
* The number that matters is the drift after it has settled. A resolver that
|
||||
* zeroes the environmental term rather than accounting for the gravity the next
|
||||
* step is about to add commits `gravity * dt^2` of penetration per step -- a
|
||||
* quarter of a pixel at 60 Hz, invisible, and cumulative.
|
||||
*/
|
||||
static akerr_ErrorContext *test_sim_lands_and_rests(void)
|
||||
{
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_CollisionShape shape;
|
||||
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
|
||||
float32_t settled = 0.0f;
|
||||
float32_t drift = 0.0f;
|
||||
int i = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
sim_arcade(900.0, 0.0, 0.0);
|
||||
CATCH(errctx, sim_collision_world());
|
||||
CATCH(errctx, sim_actor(&actor, "faller", 600.0f, 200.0f));
|
||||
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
|
||||
actor->shape = shape;
|
||||
actor->shape_override = true;
|
||||
actor->x = 32.0f;
|
||||
actor->y = 100.0f;
|
||||
|
||||
for ( i = 0; i < 120; i++ ) {
|
||||
CATCH(errctx, sim_step(SIM_DT));
|
||||
}
|
||||
settled = actor->y;
|
||||
|
||||
for ( i = 0; i < 120; i++ ) {
|
||||
CATCH(errctx, sim_step(SIM_DT));
|
||||
}
|
||||
drift = actor->y - settled;
|
||||
printf(" landed at y=%.3f, drift over 120 further steps %.4f px\n", settled, drift);
|
||||
|
||||
TEST_ASSERT(errctx, (settled < 304.0f),
|
||||
"the actor came to rest at y=%f; the floor is at 304 and it is inside it",
|
||||
settled);
|
||||
TEST_ASSERT(errctx, (fabsf(drift) < 1.0f),
|
||||
"a resting actor drifted %f px over 120 steps; it is sinking through the floor",
|
||||
drift);
|
||||
} CLEANUP {
|
||||
sim_physics.collision = NULL;
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief And it can still walk once it has landed.
|
||||
*
|
||||
* The consequence a player sees. A quarter pixel of sink is invisible until the
|
||||
* horizontal test starts reporting blocked wherever the actor stands, and then
|
||||
* the character jerks backwards every time it tries to move.
|
||||
*/
|
||||
static akerr_ErrorContext *test_sim_walks_after_landing(void)
|
||||
{
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_CollisionShape shape;
|
||||
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
|
||||
float32_t startx = 0.0f;
|
||||
int i = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
sim_arcade(900.0, 0.0, 0.0);
|
||||
CATCH(errctx, sim_collision_world());
|
||||
CATCH(errctx, sim_actor(&actor, "walker", 600.0f, 120.0f));
|
||||
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
|
||||
actor->shape = shape;
|
||||
actor->shape_override = true;
|
||||
actor->x = 32.0f;
|
||||
actor->y = 100.0f;
|
||||
|
||||
for ( i = 0; i < 120; i++ ) {
|
||||
CATCH(errctx, sim_step(SIM_DT));
|
||||
}
|
||||
startx = actor->x;
|
||||
|
||||
AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_RIGHT);
|
||||
for ( i = 0; i < 60; i++ ) {
|
||||
CATCH(errctx, sim_step(SIM_DT));
|
||||
}
|
||||
printf(" walked from x=%.2f to x=%.2f in one second\n", startx, actor->x);
|
||||
|
||||
TEST_ASSERT(errctx, (actor->x > (startx + 30.0f)),
|
||||
"an actor resting on the floor walked from %f to %f in a second; it is "
|
||||
"caught on the ground it is standing on", startx, actor->x);
|
||||
} CLEANUP {
|
||||
sim_physics.collision = NULL;
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief A fast fall does not pass through a one-tile floor.
|
||||
*
|
||||
* At 900 px/s^2 and the default 0.05 s bound a single step covers about 30
|
||||
* pixels -- nearly two 16-pixel tiles. Without sub-stepping the actor is above
|
||||
* the floor at one sample and below it at the next, and no test between them
|
||||
* ever sees an overlap.
|
||||
*/
|
||||
static akerr_ErrorContext *test_sim_fast_fall_does_not_tunnel(void)
|
||||
{
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_CollisionShape shape;
|
||||
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
|
||||
int i = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
/*
|
||||
* Zero gravity and a constant 1200 px/s, so every step is exactly 60
|
||||
* pixels and the arithmetic is the same every time. With gravity on, the
|
||||
* step size grows and the actor lands *inside* the floor by luck rather
|
||||
* than passing through it -- which made the first version of this test
|
||||
* pass with sub-stepping disabled and prove nothing.
|
||||
*
|
||||
* 60 pixels a step against a 16-pixel floor and a 16-pixel actor: the
|
||||
* window in which an overlap exists is 32 pixels wide, so from y=100 the
|
||||
* samples land at 160, 220, 280 and 340 and step clean over it. Sub-
|
||||
* stepping cuts that to 7.5 pixels, which cannot miss.
|
||||
*/
|
||||
sim_arcade(0.0, 0.0, 0.0);
|
||||
CATCH(errctx, sim_collision_world());
|
||||
CATCH(errctx, sim_actor(&actor, "bullet", 600.0f, 200.0f));
|
||||
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
|
||||
actor->shape = shape;
|
||||
actor->shape_override = true;
|
||||
actor->x = 32.0f;
|
||||
actor->y = 100.0f;
|
||||
actor->ey = 1200.0f;
|
||||
|
||||
for ( i = 0; i < 20; i++ ) {
|
||||
CATCH(errctx, sim_step(0.05f));
|
||||
}
|
||||
printf(" 60 px per step at a 16 px floor: ended at y=%.2f\n", actor->y);
|
||||
|
||||
TEST_ASSERT(errctx, (actor->y < 320.0f),
|
||||
"an actor moving 60 px a step ended at y=%f, past the floor at 304; it "
|
||||
"stepped straight over the 32 px window in which an overlap exists",
|
||||
actor->y);
|
||||
} CLEANUP {
|
||||
sim_physics.collision = NULL;
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
int failures = 0;
|
||||
int total = 0;
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
@@ -637,8 +834,16 @@ int main(void)
|
||||
failures += sim_run("top-down walk", &test_sim_topdown_walk);
|
||||
failures += sim_run("top-down diagonal speed", &test_sim_topdown_diagonal_speed);
|
||||
failures += sim_run("sudden reversal", &test_sim_sudden_reversal);
|
||||
total = 7;
|
||||
|
||||
printf("\n%d of 7 simulations failed\n", failures);
|
||||
failures += sim_run("lands and rests", &test_sim_lands_and_rests);
|
||||
failures += sim_run("walks after landing", &test_sim_walks_after_landing);
|
||||
failures += sim_run("fast fall does not tunnel", &test_sim_fast_fall_does_not_tunnel);
|
||||
total += 3;
|
||||
|
||||
// Derived, not written out. The literal that used to be here went stale
|
||||
// the first time a simulation was added, which is what a literal does.
|
||||
printf("\n%d of %d simulations failed\n", failures, total);
|
||||
FAIL_NONZERO_BREAK(errctx, failures, AKGL_ERR_BEHAVIOR,
|
||||
"%d physics simulations do not behave as a game needs", failures);
|
||||
} CLEANUP {
|
||||
|
||||
Reference in New Issue
Block a user