// Space Taxi -- taxi physics. // // Faithful translation of the C64's $6032 per-frame physics routine // (see MECHANICS.md "Per-frame algorithm" and "Velocity model // (CORRECTED)"). Acceleration-based: stick deflection sets an INSTANT // per-frame acceleration; gravity is a constant acceleration always // added (Y, signed -- level K is anti-gravity); both feed a persistent // velocity accumulator that integrates into position each frame. // Release the stick and velocity persists -- the cab drifts. // // Edge handling MATCHES the C64's $6AED behaviour: vx reflects at // column 23 going left or column 65 going right. The cab BOUNCES off // the screen edges rather than stopping. // // Crash detection: any contact with a solid non-pad tile crashes the // cab. Any contact with a pad-surface tile is a successful landing. // The C64 game does the same via sprite-bg hardware collision plus // the $7D75 trampoline predicate; we substitute explicit pad-vs-wall // tile classification. // // Per-level physics templates ($7D8F-$7D96 in the C64; xAccel/yAccel/ // xGrav/yGrav fields on StLevelT in the port) make every level feel // different -- accel ranges $0E..$40, Y-gravity ranges $F9 (-7, // anti-gravity on level K) to $06 (heavy on levels D/E/H/M/etc.). #include #include "spacetaxi.h" JOEYLIB_SEGMENT("STAXI") // Velocity safety clamp. With ST_SUBPIXEL = 256, ST_MAX_VX = 512 caps // the cab at ~2 px/frame which is in line with the C64 max effective // velocity (the C64's int16 accumulator integrates accel=14..64 over // many frames; left uncapped it'd wrap, but real-game effective speeds // stay in the hundreds-of-sub-units range). Earlier 127 capped at // 0.5 px/frame, making the cab feel glued to molasses. #define ST_MAX_VX 512 #define ST_MAX_VY 512 // Initial downward velocity injected at crash. The C64 sets $714F = // $03 and $714E = $03 at $6A4E-$6A53 -- a 16-bit vy of $0303 = 771 // sub-units (~3 px/frame). The port's ST_MAX_VY caps the effective // max, so we just start at that ceiling for a strong opening fall; // gravity (the level's own yGrav) then integrates normally during // the death anim. On anti-gravity levels (K = -7) the initial high // downward velocity dominates for many frames before being slowed // or reversed -- matches the C64's behavior on those levels. #define ST_CRASH_FALL_VY ST_MAX_VY #define ST_TAXI_W_PX 24 #define ST_TAXI_H_PX 24 // Death-animation duration. Matches the C64 sequence at $6A72/$6B24/ // $6B4C: phase-1 keeps integrating Y velocity until the cab falls to // the floor (row >= $DA = 218), then phase-2 walks the sprite cels // $CC..$D1 with a rate that slows each step, then phase-3 holds for // 70 frames before the finalize (DEC lives, blank sprite). 120 host // frames here covers the full "fall + sit" sequence at the port's // physics scale. The cab keeps falling under gravity during the // countdown so the visual matches the original "watch it drop". #define ST_CRASH_ANIM_FRAMES 120u // Edge reflection at the visible playfield edges. The C64's $6AED // thresholds (col 23 left / col 65 in X-MSB high half) are in VIC // sprite-X coordinates where X=24 is the left visible column -- so // "col 23" means "cab leftedge at visible left edge". Our port has // no VIC-X offset (X=0 IS the visible left edge), so the equivalent // is "bounce when the cab tries to leave the visible playfield". static bool isSolidAt(const StLevelT *level, int16_t px, int16_t py); static bool onLandingPad(const StLevelT *level, int16_t px, int16_t py, uint8_t *outPad); static void reflectAtEdges(StTaxiT *t); static void respawnTaxi(StGameT *game); static bool isSolidAt(const StLevelT *level, int16_t px, int16_t py) { int16_t tx = (int16_t)(px / ST_TILE_PIXELS); int16_t ty = (int16_t)(py / ST_TILE_PIXELS); uint8_t tile; if (tx < 0 || tx >= (int16_t)ST_TILEMAP_W) { return true; // off-screen sides treated as walls } if (ty < 0 || ty >= (int16_t)ST_PLAYFIELD_ROWS) { return true; // top/bottom out-of-field treated as walls } tile = level->tilemap[ty * ST_TILEMAP_W + tx]; // Tile-index convention (authored by the level designer): // 0 = empty space // 1..63 = solid (walls, platforms, structure) // 64..127 = landing-pad surfaces (also solid for collision) // 128+ = decorative non-solid (lights, signs, etc.) return tile != 0u && tile < 128u; } static bool onLandingPad(const StLevelT *level, int16_t px, int16_t py, uint8_t *outPad) { uint8_t i; uint8_t tx; uint8_t ty; tx = (uint8_t)(px / ST_TILE_PIXELS); ty = (uint8_t)(py / ST_TILE_PIXELS); for (i = 0u; i < level->padCount; i++) { const StPadT *p = &level->pads[i]; if (ty == p->tileY && tx >= p->tileX && tx < (uint8_t)(p->tileX + p->tileW)) { if (outPad != NULL) { *outPad = i; } return true; } } return false; } static void reflectAtEdges(StTaxiT *t) { // C64 $6AED behavior translated to the port's 0..319 visible // playfield: when vx is negative and the cab leftedge reaches 0, // negate vx. Same on the right when leftedge reaches (320 - W). // The cab visually touches the screen edge and bounces back; // matches the C64's "screen-edge bumper" behavior. Y has no // bounce -- the cab just stops at top/bottom (no real level // throws the cab against those edges). int32_t maxX = ((int32_t)ST_TILEMAP_W * ST_TILE_PIXELS - ST_TAXI_W_PX) * ST_SUBPIXEL; int32_t maxY = ((int32_t)ST_PLAYFIELD_ROWS * ST_TILE_PIXELS - ST_TAXI_H_PX) * ST_SUBPIXEL; if (t->x < 0 && t->vx < 0) { t->vx = (int16_t)(-t->vx); t->x = 0; } if (t->x > maxX && t->vx > 0) { t->vx = (int16_t)(-t->vx); t->x = maxX; } if (t->y < 0) { t->y = 0; t->vy = 0; } if (t->y > maxY) { t->y = maxY; t->vy = 0; } } static void respawnTaxi(StGameT *game) { StTaxiT *t = &game->taxi; int32_t spawnX; int32_t spawnY; uint8_t i; spawnX = (int32_t)game->level.taxiSpawnTileX * ST_TILE_PIXELS * ST_SUBPIXEL; spawnY = (int32_t)game->level.taxiSpawnTileY * ST_TILE_PIXELS * ST_SUBPIXEL; t->x = spawnX; t->y = spawnY; t->vx = 0; t->vy = 0; t->landed = false; t->onPad = 0xFFu; t->thrusting = false; // Boot any in-flight passenger out of the cab; a respawn doesn't // keep the fare. Waiting passengers (still on a pad) survive. for (i = 0u; i < ST_MAX_PASSENGERS; i++) { if (game->passengers[i].active && game->passengers[i].onboard) { game->passengers[i].active = false; game->passengers[i].onboard = false; } } } void stEngineReset(StGameT *game) { StTaxiT *t = &game->taxi; const StLevelT *L = &game->level; memset(t, 0, sizeof(*t)); t->x = (int32_t)L->taxiSpawnTileX * ST_TILE_PIXELS * ST_SUBPIXEL; t->y = (int32_t)L->taxiSpawnTileY * ST_TILE_PIXELS * ST_SUBPIXEL; t->facing = ST_DIR_RIGHT; t->onPad = 0xFFu; // Delegated to stPassenger.c so the fare-cursor (gNextFareIdx) and // the seed-spawn share one code path. Previously stEngineReset // spawned fares[0] inline without advancing the cursor, which then // caused spawnNextFare to re-spawn fare 0 instead of fare 1. stPassengerReset(game); } void stEngineTick(StGameT *game) { StTaxiT *t = &game->taxi; const StLevelT *L = &game->level; int32_t nx; int32_t ny; int16_t ax; int16_t ay; int16_t centerX; int16_t feetY; uint8_t landingPad; // Death animation: mirror the C64 sequence -- the cab keeps // FALLING through phase 1 ($6A72) until it hits the floor, then // sits there through phases 2/3 before respawn. No input accepted, // no further crash checks (otherwise hitting the floor on the way // down would re-trigger). Mute thrust input so applyInput's // per-frame joystick read can't keep the SFX going. // // Uses the LEVEL's normal gravity during integration -- the C64 // doesn't override gravity at crash, it just injects a high // downward vy at crash entry and then lets normal physics run. // On level K (anti-gravity = -7) the high initial vy dominates // for many frames before being slowed/reversed; matches C64. if (t->crashTicks > 0u) { int32_t maxY = ((int32_t)ST_PLAYFIELD_ROWS * ST_TILE_PIXELS - ST_TAXI_H_PX) * ST_SUBPIXEL; t->thrusting = false; t->vy = (int16_t)(t->vy + (int16_t)L->yGrav); if (t->vy > ST_MAX_VY) { t->vy = ST_MAX_VY; } if (t->vy < -ST_MAX_VY) { t->vy = -ST_MAX_VY; } t->y += t->vy; if (t->y > maxY) { t->y = maxY; t->vy = 0; } t->crashTicks--; if (t->crashTicks == 0u) { if (game->lives > 0u) { game->lives--; } if (game->lives == 0u) { game->state = ST_STATE_GAME_OVER; } else { respawnTaxi(game); } } return; } // Step 1: instant acceleration from stick. ax = (int16_t)((int16_t)t->thrustDx * (int16_t)L->xAccel); ay = (int16_t)((int16_t)t->thrustDy * (int16_t)L->yAccel); if (!t->thrusting) { ax = 0; ay = 0; } // Step 2: integrate accel + per-level gravity into velocity. // Gravity is int8 signed so a level can pull upward (level K). t->vx = (int16_t)(t->vx + ax + (int16_t)L->xGrav); t->vy = (int16_t)(t->vy + ay + (int16_t)L->yGrav); if (t->thrusting) { // Thrust-flame cel cycling. The asset (genPlaceholderArt.py) // authors 3 thrust-flame variants at cels 1..3. Advance the // counter every frame; cel selection in stRender divides by // ST_THRUST_CEL_TICKS so the visible animation runs at ~10 Hz. t->thrustFrame++; if (t->thrustFrame >= (uint8_t)(ST_THRUST_CEL_COUNT * ST_THRUST_CEL_TICKS)) { t->thrustFrame = 0u; } } else { t->thrustFrame = 0u; } // Safety clamp on the velocity accumulator -- without this a long // fall under gravity (or sustained one-way thrust against no // collision) lets vx/vy wrap int16_t and reverse sign. if (t->vx > ST_MAX_VX) { t->vx = ST_MAX_VX; } if (t->vx < -ST_MAX_VX) { t->vx = -ST_MAX_VX; } if (t->vy > ST_MAX_VY) { t->vy = ST_MAX_VY; } if (t->vy < -ST_MAX_VY) { t->vy = -ST_MAX_VY; } nx = t->x + t->vx; ny = t->y + t->vy; // Wall collision (X-axis): sample the cab's leading edge at three // Y rows (top, middle, bottom). If any sample hits a solid cell, // undo X movement and zero vx so the cab stops against the wall. { int16_t leadX = (t->vx > 0) ? (int16_t)((nx >> ST_SUBPIXEL_SHIFT) + ST_TAXI_W_PX - 1) : (int16_t)(nx >> ST_SUBPIXEL_SHIFT); int16_t topY = (int16_t)(t->y >> ST_SUBPIXEL_SHIFT); int16_t midY = (int16_t)(topY + ST_TAXI_H_PX / 2); int16_t botY = (int16_t)(topY + ST_TAXI_H_PX - 1); if (isSolidAt(L, leadX, topY) || isSolidAt(L, leadX, midY) || isSolidAt(L, leadX, botY)) { nx = t->x; t->vx = 0; } } // Y-axis collision. The C64 game has no velocity threshold: any // sprite-vs-background contact crashes the cab unless the cab is // touching a pad surface (in which case it's a successful landing // regardless of descent speed). We do the same via tile lookup. feetY = (int16_t)((ny >> ST_SUBPIXEL_SHIFT) + ST_TAXI_H_PX - 1); centerX = (int16_t)((nx >> ST_SUBPIXEL_SHIFT) + ST_TAXI_W_PX / 2); if (t->vy > 0) { if (isSolidAt(L, centerX, feetY)) { if (onLandingPad(L, centerX, feetY, &landingPad)) { ny = (int32_t)(feetY / ST_TILE_PIXELS) * ST_TILE_PIXELS * ST_SUBPIXEL - (int32_t)ST_TAXI_H_PX * ST_SUBPIXEL; t->vx = 0; t->vy = 0; t->landed = true; t->onPad = landingPad; } else { stAudioSfxCrash(); // Enter the falling-death state. Zero vx so the cab // doesn't drift sideways during the fall, and inject // a high downward vy matching the C64's $6A4E-$6A53 // setup (which writes $0303 = 771 sub-units into // $714E/$714F regardless of pre-impact state). The // crashTicks gate at the top of stEngineTick handles // gravity integration + respawn from here. t->vx = 0; t->vy = ST_CRASH_FALL_VY; t->thrusting = false; t->crashTicks = ST_CRASH_ANIM_FRAMES; } } else { t->landed = false; t->onPad = 0xFFu; } } else if (t->vy < 0) { // Ascending: head hits ceiling? Stop vertical motion, leave // horizontal alone so the cab can drift along the underside. int16_t headY = (int16_t)(ny >> ST_SUBPIXEL_SHIFT); if (isSolidAt(L, centerX, headY)) { ny = t->y; t->vy = 0; } t->landed = false; } t->x = nx; t->y = ny; reflectAtEdges(t); }