// 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" // 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]; // The tilemap holds raw C64 screen-RAM character codes (romToLevel.py // copies screen RAM directly; the empty cell is the space character // $20, BG=0x20 in the extractor -- confirmed by the .dat histogram // where char 32 dominates the tilemap). On the C64 a sprite-vs- // background collision ($D01F) latches on ANY non-blank background // cell the cab overlaps, so a cell is solid iff its char code is not // the space character. (The earlier "1..63 solid / 64..127 pad / // 128+ decorative" convention was invented and is wrong against the // real data -- it treated char 32, the empty playfield, as solid, // which crashes the cab the instant it descends into open air.) // // The transporter opening ($67, the 4-tile top-wall gap) is the one // non-space tile the cab may pass through: it's the screen exit. It // only ever occurs at row 0 cols 18..21, so treating it as passable // everywhere is safe. return tile != ST_TILE_EMPTY && tile != ST_TILE_TRANSPORTER; } 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)) { // The C64 pad detector ($645C) only registers a landing on a // SLOW descent: it gates on the Y-velocity high byte being // zero (vy < 1 px/frame). A fast descent fails the gate, so // the pad surface acts as solid background and the cab // crashes into it -- this is the "land gently or die" // mechanic. In the port's 8-bit sub-pixel scale that high- // byte-zero test is vy < ST_SUBPIXEL (256). bool slowDescent = (t->vy < ST_SUBPIXEL); if (slowDescent && 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); } bool stEngineInTransporter(const StGameT *game) { const StTaxiT *t = &game->taxi; int16_t topY; int16_t centerCol; // The cab has exited when its top has risen into row 0 while its // center sits within the transporter columns (18..21). The opening // tile ($67) is passable (see isSolidAt), so the cab can ascend the // empty channel and reach row 0 only through the gap -- the walls // stop it everywhere else. topY = (int16_t)(t->y >> ST_SUBPIXEL_SHIFT); centerCol = (int16_t)(((t->x >> ST_SUBPIXEL_SHIFT) + ST_TAXI_W_PX / 2) / ST_TILE_PIXELS); // Fire once the cab has risen into the opening (top within rows 0-1). // The walls block every column but the 18-21 gap, so reaching here at // all means the cab is exiting through the transporter. return topY < (int16_t)(2 * ST_TILE_PIXELS) && centerCol >= (int16_t)ST_TRANSPORTER_COL_LO && centerCol < (int16_t)ST_TRANSPORTER_COL_HI; } // Transporter shrink-warp ($5C91 takeoff + $5CB9 pad2pad). The C64 walks // the cab sprite through shrink cels $E2..$E9 (8 cels / 7 steps) while // nudging it +1px right / -1px up per step, with a cadence that slows as // it recedes: 4 frames/step for the first steps, 16 frames/step for the // last. Step boundaries (cumulative frames): 4,8,12,16,32,48,64. Total 64 // frames (~1.1s). The port has no shrink cels, so the renderer scales a // block by stEngineWarpStep instead. #define ST_WARP_TOTAL_FRAMES 64u static const uint8_t kWarpStepFrame[8] = { 0u, 4u, 8u, 12u, 16u, 32u, 48u, 64u }; uint8_t stEngineWarpStep(const StGameT *game) { uint8_t frame = game->taxi.warpFrame; uint8_t step = 0u; uint8_t k; for (k = 0u; k < 8u; k++) { if (frame >= kWarpStepFrame[k]) { step = k; } } return step; } void stEngineStartWarp(StGameT *game) { game->taxi.warpFrame = 1u; game->taxi.vx = 0; game->taxi.vy = 0; game->taxi.thrusting = false; stAudioSfxCrash(); // the C64 sets voice-3 noise ($81) on warp start } bool stEngineWarpTick(StGameT *game) { StTaxiT *t = &game->taxi; uint8_t before; uint8_t after; before = stEngineWarpStep(game); t->warpFrame++; after = stEngineWarpStep(game); // Nudge +1px right / -1px up on each step boundary (net +7/-7). if (after != before) { t->x += (int32_t)ST_SUBPIXEL; t->y -= (int32_t)ST_SUBPIXEL; } if (t->warpFrame >= (uint8_t)ST_WARP_TOTAL_FRAMES) { t->warpFrame = 0u; return true; } return false; }