Fix three arcade physics defects the unit tests could not see
tests/physics_sim.c runs the arcade backend the way a game does -- a Mario-esque jump and fall, Zelda-style top-down walking, and a run reversed at full speed -- and prints what the actor actually did. tests/physics.c already checked that akgl_physics_simulate does the arithmetic it claims. Every one of these got past it. The first step was however long the level took to load. gravity_time was never initialized and dt was unbounded, so a 250 ms load produced a 250 ms step: 100 px of fall where a 60 Hz frame under the same gravity is 0.44 px, straight through whatever was underneath. Both initializers seed the clock, and simulate bounds dt to max_timestep -- a new physics.max_timestep property, default 0.05 s, read like gravity and drag. Zero disables the bound. The field replaces the dead timer_gravity, so the struct is the same size. Releasing a vertical key cancelled gravity. The _off handlers zeroed ay, ey, ty and vy together, and ey is where the arcade backend accumulates gravity -- tapping down mid-jump stopped the character in the air. They clear ax/tx (or ay/ty) and nothing else now. Velocity was never theirs to clear either: simulate recomputes v as e + t every step. Diagonal movement was 41% too fast. Thrust was capped per axis, so an actor holding two directions got both caps at once and travelled their diagonal. It is capped as a vector against the sx/sy/sz ellipse, which also keeps a character whose horizontal and vertical top speeds differ moving at the ratio it asked for. An axis with a zero max speed stays out of the magnitude and is forced to zero, as the old clamp did. Each fix was checked by reverting it and confirming the simulation goes red: 100.1 px, vy 0.0 after a down tap, and 141% diagonal respectively. tests/actor.c and tests/physics.c pinned the old behaviour in both places and now assert the new contract. Bumped to 0.6.0: akgl_PhysicsBackend changed. TODO.md records the four things the simulations found and this does not fix -- no terminal velocity, no deceleration on release, Euler's frame-rate dependence, and a drag coefficient large enough to invert velocity. 26/26 ctest, memcheck clean, warning-clean at -Wall -Werror. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
This commit is contained in:
10
src/actor.c
10
src/actor.c
@@ -354,9 +354,7 @@ akerr_ErrorContext *akgl_actor_cmhf_left_off(akgl_Actor *obj, SDL_Event *event)
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
|
||||
//SDL_Log("event %d (button %d / key %d) stops moving actor left", event->type, event->gbutton.which, event->key.key);
|
||||
obj->ax = 0;
|
||||
obj->ex = 0;
|
||||
obj->tx = 0;
|
||||
obj->vx = 0;
|
||||
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT);
|
||||
//SDL_Log("new target actor state: %b", obj->state);
|
||||
SUCCEED_RETURN(errctx);
|
||||
@@ -383,9 +381,7 @@ akerr_ErrorContext *akgl_actor_cmhf_right_off(akgl_Actor *obj, SDL_Event *event)
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
|
||||
//SDL_Log("event %d (button %d / key %d) stops moving actor right", event->type, event->gbutton.which, event->key.key);
|
||||
obj->ax = 0;
|
||||
obj->ex = 0;
|
||||
obj->tx = 0;
|
||||
obj->vx = 0;
|
||||
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT);
|
||||
//SDL_Log("new target actor state: %b", obj->state);
|
||||
SUCCEED_RETURN(errctx);
|
||||
@@ -412,9 +408,7 @@ akerr_ErrorContext *akgl_actor_cmhf_up_off(akgl_Actor *obj, SDL_Event *event)
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
|
||||
//SDL_Log("event %d (button %d / key %d) stops moving actor up", event->type, event->gbutton.which, event->key.key);
|
||||
obj->ay = 0;
|
||||
obj->ey = 0;
|
||||
obj->ty = 0;
|
||||
obj->vy = 0;
|
||||
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
|
||||
//SDL_Log("new target actor state: %b", obj->state);
|
||||
SUCCEED_RETURN(errctx);
|
||||
@@ -440,10 +434,8 @@ akerr_ErrorContext *akgl_actor_cmhf_down_off(akgl_Actor *obj, SDL_Event *event)
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
|
||||
//SDL_Log("event %d (button %d / key %d) stops moving actor down", event->type, event->gbutton.which, event->key.key);
|
||||
obj->ty = 0;
|
||||
obj->ey = 0;
|
||||
obj->ay = 0;
|
||||
obj->vy = 0;
|
||||
obj->ty = 0;
|
||||
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_DOWN);
|
||||
//SDL_Log("new target actor state: %b", obj->state);
|
||||
SUCCEED_RETURN(errctx);
|
||||
|
||||
@@ -47,6 +47,12 @@ akerr_ErrorContext *akgl_physics_init_null(akgl_PhysicsBackend *self)
|
||||
self->move = akgl_physics_null_move;
|
||||
self->simulate = akgl_physics_simulate;
|
||||
|
||||
// Set for the same reason as the arcade backend: the null backend still
|
||||
// goes through akgl_physics_simulate, which still computes a dt and still
|
||||
// commits velocity to position through akgl_physics_null_move.
|
||||
self->gravity_time = SDL_GetTicksNS();
|
||||
self->max_timestep = AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP;
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -104,6 +110,14 @@ akerr_ErrorContext *akgl_physics_init_arcade(akgl_PhysicsBackend *self)
|
||||
self->move = akgl_physics_arcade_move;
|
||||
self->simulate = akgl_physics_simulate;
|
||||
|
||||
// The epoch the first step's dt is measured from. Nothing set it, so it
|
||||
// stayed at whatever the backend's storage held -- zero, for the default
|
||||
// backend in BSS -- and the first akgl_physics_simulate() measured dt as
|
||||
// the entire time since SDL started. A host that spends a quarter of a
|
||||
// second loading before its first frame got a quarter-second step: 101
|
||||
// pixels of fall where a 60 Hz frame is 0.44.
|
||||
self->gravity_time = SDL_GetTicksNS();
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_get_property("physics.gravity.x", &tmp, "0.0"));
|
||||
CATCH(errctx, aksl_atof(tmp->data, &self->gravity_x));
|
||||
@@ -117,6 +131,8 @@ akerr_ErrorContext *akgl_physics_init_arcade(akgl_PhysicsBackend *self)
|
||||
CATCH(errctx, aksl_atof(tmp->data, &self->drag_y));
|
||||
CATCH(errctx, akgl_get_property("physics.drag.z", &tmp, "0.0"));
|
||||
CATCH(errctx, aksl_atof(tmp->data, &self->drag_z));
|
||||
CATCH(errctx, akgl_get_property("physics.max_timestep", &tmp, "0.05"));
|
||||
CATCH(errctx, aksl_atof(tmp->data, &self->max_timestep));
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(tmp));
|
||||
} PROCESS(errctx) {
|
||||
@@ -134,6 +150,8 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat
|
||||
};
|
||||
SDL_Time curtime = 0;
|
||||
float32_t dt = 0;
|
||||
float32_t overshoot = 0.0f;
|
||||
float32_t thrustscale = 0.0f;
|
||||
akgl_Actor *actor = NULL;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
@@ -144,6 +162,23 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat
|
||||
curtime = SDL_GetTicksNS();
|
||||
dt = (float32_t)(curtime - self->gravity_time) / (float32_t)AKGL_TIME_ONESEC_NS;
|
||||
|
||||
// Bound the step. Everything below multiplies by dt, so one enormous dt
|
||||
// moves every actor by however far its velocity carries it over the whole
|
||||
// stall -- a quarter-second load is a hundred pixels of fall under
|
||||
// platformer gravity, straight through whatever was underneath. Advancing
|
||||
// the world in slow motion through a hitch is the trade to make here.
|
||||
//
|
||||
// A zero or negative max_timestep disables the bound, for a caller who
|
||||
// would rather have the real elapsed time and handle it themselves.
|
||||
if ( (self->max_timestep > 0.0) && (dt > (float32_t)self->max_timestep) ) {
|
||||
dt = (float32_t)self->max_timestep;
|
||||
}
|
||||
// A clock that went backwards is not a step. SDL_GetTicksNS is monotonic,
|
||||
// but gravity_time is a public field and a caller can put anything in it.
|
||||
if ( dt < 0.0f ) {
|
||||
dt = 0.0f;
|
||||
}
|
||||
|
||||
if ( opflags == NULL ) {
|
||||
opflags = &defflags;
|
||||
}
|
||||
@@ -179,27 +214,40 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat
|
||||
actor->ty += actor->ay * dt;
|
||||
}
|
||||
|
||||
// velocity equals thrust unless thrust exceeds max speed
|
||||
if ( fabsf(actor->tx) > fabsf(actor->sx) ) {
|
||||
if ( actor->tx < 0 ) {
|
||||
actor->tx = -actor->sx;
|
||||
} else {
|
||||
actor->tx = actor->sx;
|
||||
}
|
||||
// Cap the thrust *vector* against the ellipse the character's per-axis
|
||||
// top speeds describe, rather than each axis against its own cap.
|
||||
//
|
||||
// Capping the axes independently lets the corner of the box through: an
|
||||
// actor holding two directions at once got both caps at once and
|
||||
// travelled their diagonal, which measured 41% faster than either alone.
|
||||
// Scaling to the ellipse keeps a character whose horizontal and vertical
|
||||
// speeds differ moving at the ratio it asked for, and makes them equal
|
||||
// where the speeds are.
|
||||
//
|
||||
// An axis with a top speed of zero cannot be thrust along at all, which
|
||||
// is what the old per-axis clamp did with it, and it stays out of the
|
||||
// magnitude entirely -- dividing by it would not end well.
|
||||
overshoot = 0.0f;
|
||||
if ( actor->sx != 0.0f ) {
|
||||
overshoot += (actor->tx / actor->sx) * (actor->tx / actor->sx);
|
||||
} else {
|
||||
actor->tx = 0.0f;
|
||||
}
|
||||
if ( fabsf(actor->ty) > fabsf(actor->sy) ) {
|
||||
if ( actor->ty < 0 ) {
|
||||
actor->ty = -actor->sy;
|
||||
} else {
|
||||
actor->ty = actor->sy;
|
||||
}
|
||||
if ( actor->sy != 0.0f ) {
|
||||
overshoot += (actor->ty / actor->sy) * (actor->ty / actor->sy);
|
||||
} else {
|
||||
actor->ty = 0.0f;
|
||||
}
|
||||
if ( fabsf(actor->tz) > fabsf(actor->sz) ) {
|
||||
if ( actor->tz < 0 ) {
|
||||
actor->tz = -actor->sz;
|
||||
} else {
|
||||
actor->tz = actor->sz;
|
||||
}
|
||||
if ( actor->sz != 0.0f ) {
|
||||
overshoot += (actor->tz / actor->sz) * (actor->tz / actor->sz);
|
||||
} else {
|
||||
actor->tz = 0.0f;
|
||||
}
|
||||
if ( overshoot > 1.0f ) {
|
||||
thrustscale = 1.0f / sqrtf(overshoot);
|
||||
actor->tx *= thrustscale;
|
||||
actor->ty *= thrustscale;
|
||||
actor->tz *= thrustscale;
|
||||
}
|
||||
ATTEMPT {
|
||||
CATCH(errctx, actor->movementlogicfunc(actor, dt));
|
||||
|
||||
Reference in New Issue
Block a user