1395 lines
60 KiB
C
1395 lines
60 KiB
C
// Space Taxi -- tile bank + sprite rendering.
|
|
//
|
|
// Loads native (.tbk / .spr) assets at startup via jlTileBankLoad
|
|
// and jlSpriteBankLoad. Tile bytes are per-target planar; sprite
|
|
// data is cross-target chunky 4bpp (the Phase 11 walker reads
|
|
// chunky and c2p's inline at draw time). Per-frame work: save-
|
|
// under for moving sprites, draw, restore-under next frame. The
|
|
// static tilemap is committed once per scene change.
|
|
//
|
|
// Source PNGs live in assets/ and are baked at build time by
|
|
// tools/assetbake/assetbake.py:
|
|
// font.png -> font.tbk (1000-tile 40x25 glyph sheet)
|
|
// tiles/tbankN.png -> tiles/tbankN.tbk (256-tile playfield bank)
|
|
// sprites/sprites.png -> sprites/sprites.spr (9x3 grid of 3x3
|
|
// 24x24 cels: row 0 taxi, row 1 passenger, row 2 flame)
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#include "spacetaxi.h"
|
|
|
|
// All STAXI example sources share the STAXI load segment so the
|
|
// IIgs binary's _ROOT bank stays under 64 KB. No-op on other ports.
|
|
|
|
// Tile bank files are per-level: each level's .dat references a
|
|
// numeric bank id, and the host loads tiles/tbankN.tbk on demand
|
|
// (cached so re-entry on the same id is free).
|
|
//
|
|
// Per JoeyLib convention: each app installs into its own subdir
|
|
// of bin/, with runtime assets under DATA/. The DOS binary cwd's
|
|
// to the app dir when launched, so these paths are relative.
|
|
// DOS 8.3 filename limit: "tilebank0.tbk" is 9.3, fopen fails under
|
|
// DOSBox strict 8.3. Shortened to "tbank%u.tbk" (6.3) so all four
|
|
// targets can use the same filenames without per-platform aliasing.
|
|
#define ST_TILE_BANK_PATH_FMT "tiles/tbank%u.tbk"
|
|
#define ST_SPRITE_SHEET_PATH "sprites/sprites.spr"
|
|
#define ST_SPRITE_SHEET_SPC "sprites/sprites.spc"
|
|
#define ST_FONT_PATH "font.tbk"
|
|
|
|
#define ST_TILE_BANK_MAX 256u
|
|
|
|
// Font sheet layout: 320x200 indexed PNG = 40x25 grid of 8x8 glyphs,
|
|
// 1000 tiles total. asciiMap[c] packs the (col, row) location for
|
|
// ASCII c into a uint16_t; jlDrawText looks the tile up and pastes
|
|
// it as a transparent-on-color-0 glyph.
|
|
#define ST_FONT_COLS 40
|
|
#define ST_FONT_ROWS 25
|
|
#define ST_FONT_TILES_MAX (ST_FONT_COLS * ST_FONT_ROWS)
|
|
|
|
// Sprite sheet is 9 cols x 3 rows of 24x24 (= 3x3 tile) cels. Row 0
|
|
// is the taxi (4 cels used, rest blank), row 1 is the passenger (9
|
|
// cels), row 2 is the flame (8 cels). Cells lay out left-to-right
|
|
// top-to-bottom in the .spr blob's cellCount = 27.
|
|
#define ST_SPRITE_SHEET_COLS 9
|
|
#define ST_SPRITE_SHEET_CELS 55 /* 11 cols x 5 rows: taxi, passenger, flame, warp, death */
|
|
#define ST_SPRITE_TAXI_FIRST 0
|
|
#define ST_SPRITE_PASS_FIRST 11
|
|
#define ST_SPRITE_FLAME_FIRST 22
|
|
// Taxi sprite: 24x24 px = 3 tiles wide x 3 tiles tall. The port's
|
|
// sprite asset (extractSprites.py, real C64 art from raw.bin) lays
|
|
// out sheet row 0 in the live sprite-pointer order:
|
|
// cel 0 = $C1 (right, gear DOWN) cel 1 = $C0 (right, gear up)
|
|
// cel 2 = $DC (left, gear up) cel 3 = $DD (left, gear DOWN)
|
|
// $619B selects the base pair by held direction (LEFT -> $DC pair,
|
|
// RIGHT -> $C0 pair, otherwise keep the last facing) and preserves
|
|
// bit 0 = the LANDING GEAR state (fire-toggled, $63DD). The in-
|
|
// flight body NEVER animates -- the cel is a static function of
|
|
// (facing, gearDown):
|
|
// cel = facingLeft ? (2 + gearDown) : (1 - gearDown)
|
|
// (The $6A95 EOR that an earlier port version modeled as a flicker
|
|
// is the crash-debris animation, $CC<->$CD during the fall.)
|
|
#define ST_TAXI_W_PX 24
|
|
#define ST_TAXI_H_PX 24
|
|
#define ST_TAXI_CEL_COUNT 4
|
|
// Transporter shrink-warp cels (sheet row 3): the SEVEN drawn cels
|
|
// $E2..$E8 of the $5CB9 chain, identity-mapped to warp steps 0..6.
|
|
// $5CDA ends the warp when the pointer REACHES $E9 and hides the
|
|
// sprite ($718E = 0) -- $E9 is never displayed, so step 7 draws
|
|
// nothing at all.
|
|
#define ST_SPRITE_WARP_FIRST 33
|
|
#define ST_WARP_CEL_COUNT 7
|
|
|
|
// Flame placeholder fallback (only used if real sprite-2 cel is
|
|
// missing): a small bright rectangle below the cab while thrusting.
|
|
// $6D6A in the asm positions sprite 2 at (taxi_col - 2, taxi_row);
|
|
// the real cels are extracted at runtime as flameCels[0..7].
|
|
#define ST_FLAME_W_PX 8
|
|
#define ST_FLAME_H_PX 8
|
|
#define ST_FLAME_OFFSET_X_PX ((ST_TAXI_W_PX / 2) - (ST_FLAME_W_PX / 2))
|
|
#define ST_FLAME_OFFSET_Y_PX (ST_TAXI_H_PX - 2)
|
|
|
|
// Real flame sprite cels (24x24, from raw.bin sprite ptrs in $6DB0
|
|
// table). Indexed by direction-mask -> cel via kFlameCelByDirMask
|
|
// below. $6D6A's parity bit ($716C) flickers the flame off every
|
|
// other GAME TICK (2 video frames, so a 12.5Hz PAL strobe that fuses
|
|
// on a CRT). The port reproduces the LOOK with a wall-clock virtual
|
|
// strobe (flameStrobeOn): at high frame rates it yields the authentic
|
|
// shimmer; a rendered frame long enough to span a whole strobe phase
|
|
// (low-fps IIgs) shows the flame continuously instead of a 7.5Hz
|
|
// blink that reads as "no flame".
|
|
#define ST_FLAME_CEL_W_PX 24
|
|
#define ST_FLAME_CEL_H_PX 24
|
|
#define ST_FLAME_CEL_COUNT 8
|
|
#define ST_FLAME_STROBE_MS 40u /* one $716C phase = 1 PAL game tick */
|
|
|
|
// Passenger: 24x24 (C64 hardware sprite size is 24x21; we pad 3 rows
|
|
// transparent at the bottom in the asset extraction to keep a square
|
|
// cell). Cels:
|
|
// 0/1 = walk-LEFT alternation ($C4 / $C5) for the title walk
|
|
// 2-7 = boarding sequence ($C6 / $C7 / $C8 / $C9 / $CA / $CB) per
|
|
// $67A6 incrementing gSpr1PtrShadow from $C6 toward $CC.
|
|
#define ST_PASSENGER_W_PX 24
|
|
#define ST_PASSENGER_H_PX 24
|
|
#define ST_PASSENGER_CEL_COUNT 11
|
|
#define ST_PASSENGER_CEL_WALK0 0 /* $C4 walk LEFT a */
|
|
#define ST_PASSENGER_CEL_WALK1 1 /* $C5 walk LEFT b */
|
|
#define ST_PASSENGER_CEL_BOARD0 2 /* maps to C64 ptr $C6 */
|
|
#define ST_PASSENGER_CEL_SPARKLE 8 /* C64 ptr $D9 -- 3rd sparkle cel */
|
|
#define ST_PASSENGER_CEL_WALKR0 9 /* $C2 walk RIGHT a */
|
|
#define ST_PASSENGER_CEL_WALKR1 10 /* $C3 walk RIGHT b */
|
|
|
|
// Crash-death cels (sheet row 4): $CC/$CD debris alternation during
|
|
// the fall, then the $CE..$D1 ground-impact walk, then hidden.
|
|
#define ST_DEATH_CEL_COUNT 6
|
|
#define ST_SPRITE_DEATH_FIRST 44
|
|
|
|
// Startup progress bar: 2 load steps (font, sprite bank) + the 36
|
|
// per-cel compiles (4 taxi + 11 passenger + 8 flame + 7 warp +
|
|
// 6 death).
|
|
#define ST_LOAD_STEPS_TOTAL 38
|
|
#define ST_LOAD_BAR_X 60
|
|
#define ST_LOAD_BAR_Y 110
|
|
#define ST_LOAD_BAR_H 6
|
|
#define ST_LOAD_BAR_COLOR 1u /* white fill */
|
|
#define ST_LOAD_BAR_FRAME_COLOR 6u /* blue frame, cab color */
|
|
|
|
// Library-wide worst case (16-px-group window on planar ports).
|
|
#define ST_SPRITE_BACKUP_BYTES JOEY_SPRITE_BACKUP_BYTES(ST_TAXI_W_PX / 8, ST_TAXI_H_PX / 8)
|
|
|
|
|
|
typedef struct {
|
|
jlTileT tiles[ST_TILE_BANK_MAX];
|
|
bool tileValid[ST_TILE_BANK_MAX];
|
|
uint8_t currentBankId; // which tbank%u.tbk is loaded (0xFF = none)
|
|
bool bankLoaded;
|
|
jlSpriteT *taxiCels[ST_TAXI_CEL_COUNT];
|
|
jlSpriteT *warpCels[ST_WARP_CEL_COUNT];
|
|
jlSpriteT *deathCels[ST_DEATH_CEL_COUNT];
|
|
jlSpriteT *flameCels[ST_FLAME_CEL_COUNT];
|
|
jlSpriteBackupT flameBackup;
|
|
uint8_t flameBackupMem[ST_SPRITE_BACKUP_BYTES] __attribute__((aligned(2)));
|
|
bool flameHasBackup;
|
|
jlSpriteT *passengerCels[ST_PASSENGER_CEL_COUNT];
|
|
jlSurfaceT *fontSurface; // glyph surface, built from font.tbk at load
|
|
uint16_t asciiMap[128];
|
|
bool fontReady;
|
|
jlSpriteBackupT taxiBackup;
|
|
uint8_t taxiBackupMem[ST_SPRITE_BACKUP_BYTES] __attribute__((aligned(2)));
|
|
jlSpriteBackupT passengerBackup[ST_MAX_PASSENGERS];
|
|
uint8_t passengerBackupMem[ST_MAX_PASSENGERS][ST_SPRITE_BACKUP_BYTES] __attribute__((aligned(2)));
|
|
bool taxiHasBackup;
|
|
bool passengerHasBackup[ST_MAX_PASSENGERS];
|
|
uint32_t flamePrevMs; // flameStrobeOn frame-span tracker
|
|
uint32_t deathMs; // crash-anim step anchor
|
|
uint8_t deathStep; // impact-walk position (0..4)
|
|
bool deathActive; // crash anim state latched
|
|
// Tilemap repaint gating: the static playfield art only needs to
|
|
// be blitted to the stage once per scene change, not every frame.
|
|
// Per-frame full repaint blows the per-frame budget on emulated
|
|
// 386 in DOSBox and exposes tearing as the paint races the raster.
|
|
bool tilemapDirty;
|
|
const StLevelT *lastLevel;
|
|
} StRenderStateT;
|
|
|
|
|
|
static StRenderStateT gRender;
|
|
|
|
|
|
static bool loadTileBank(uint8_t bankId);
|
|
static bool loadSpriteSheet(jlSurfaceT *stage);
|
|
static bool loadFontSheet(jlSurfaceT *stage);
|
|
static void loadProgress(jlSurfaceT *stage, uint8_t step);
|
|
static bool flameStrobeOn(void);
|
|
static int16_t passCelForPtr(uint8_t ptr);
|
|
static void buildAsciiMap(void);
|
|
static void destroySprites(void);
|
|
|
|
// ---- Title intro + demo --------------------------------------------------
|
|
//
|
|
// Faithful to the C64 sequence (see disassembly $4A03 et al):
|
|
//
|
|
// Stage 2 ($4A17): wait $5A=90 frames running $66B7 (sparkle/effect)
|
|
// before the passenger appears.
|
|
// Stage 3 ($4A24): passenger sprite walks horizontally toward the cab,
|
|
// one tick per $715D countdown. $6D0D moves $7176
|
|
// (passenger col) +/- 2 each tick based on relative
|
|
// pad-hover X, toggling sprite cel via $7161 parity.
|
|
// Advances when passenger col == $28 (= 40, at cab).
|
|
// Stage 4 ($4A45): JMP $67A6 (transition).
|
|
// Stage 5 ($4A48): forces gInputDirMask = $01 (UP). Calls flameSpriteUpdate.
|
|
// Cab climbs until gTaxiRow < $14 = 20, then silences
|
|
// voice 3 and advances. This is the takeoff.
|
|
// Stage 6+: physicsTick now sources its input from the script at
|
|
// $0902 via $48F2. The recorded demo plays.
|
|
//
|
|
// Single cab, single passenger. The 7-sprite init at $4555-$456F just
|
|
// parks all hardware sprite slots at (col $AA, row $8C) so they don't
|
|
// flash garbage when the title is first shown.
|
|
|
|
// (The fabricated title "demo physics" stage and its truncated copy
|
|
// of demo stream 01 were deleted 2026-07-21: the real C64 never flies
|
|
// the cab around the title after takeoff -- it enters the ATTRACT
|
|
// DEMO, now a proper game state (ST_STATE_DEMO) driven by the full
|
|
// recorded input streams in stDemoStreams.h.)
|
|
|
|
// C64 reference values straight from the asm at $4A03 dispatch +
|
|
// titleSpriteSetup ($4525) + sprite-init tables ($4994/$49AC).
|
|
#define ST_TITLE_STAGE_SPARKLE 2u // gDeathStage value, $4A17
|
|
#define ST_TITLE_STAGE_WALK 3u // $4A24
|
|
#define ST_TITLE_STAGE_HANDOFF 4u // $4A45 -> $67A6
|
|
#define ST_TITLE_STAGE_LIFTOFF 5u // $4A48
|
|
#define ST_TITLE_STAGE_DONE 6u // intro over; attract demo takes over
|
|
#define ST_TITLE_SPARKLE_FRAMES 0x5Au // $47CC LDA #$5A STA $473F
|
|
#define ST_TITLE_WALK_TICK_RELOAD 3u // $715E observed in raw.bin
|
|
// Walk target = gPadHoverXCol ($715F) = $28 set at $47D8.
|
|
// In C64 sprite-X coords. Visible col = sprite-X - 24.
|
|
#define ST_TITLE_PAD_HOVER_SX 0x28u
|
|
// Cab init from table $4994/$49AC: sprite-X=$28, sprite-Y=$84, ptr=$C1.
|
|
#define ST_TITLE_CAB_SX_INIT 0x28
|
|
#define ST_TITLE_CAB_SY_INIT 0x84
|
|
// Passenger init from table: sprite-X=$32 + frac=$01 -> X=$32+$100=306,
|
|
// sprite-Y=$84, ptr=$C7.
|
|
#define ST_TITLE_PASS_SX_INIT ((int16_t)0x132) // $32 + $100
|
|
#define ST_TITLE_PASS_SY_INIT 0x84
|
|
#define ST_TITLE_PASS_PTR_INIT 0xC7 // $49B4[1]: initial passenger sprite ptr
|
|
#define ST_TITLE_PASS_PTR_WALK_LO 0xC4 // $6D68: walk-LEFT cel A
|
|
#define ST_TITLE_PASS_PTR_WALK_HI 0xC5 // $6D69: walk-LEFT cel B
|
|
#define ST_TITLE_PASS_PTR_BOARD0 0xC6 // boarding sequence start
|
|
#define ST_TITLE_PASS_PTR_BOARDED 0xCC // $67B8: advance when ptr == $CC
|
|
|
|
// $66D6 sparkle cel table -- cycled by $66B7 during stage 2.
|
|
// Each entry shown for $715E (= 3) frames; one full cycle = 12 frames.
|
|
// The $66D6 wave/sparkle cycle {$C6,$C7,$D9,$C7}: the title sparkle
|
|
// AND the waiting passenger's in-place wave both step through it (the
|
|
// gameplay passenger never moves -- he stands at standX and waves).
|
|
static const uint8_t kPassengerWaveCels[4] = { 0xC6, 0xC7, 0xD9, 0xC7 };
|
|
|
|
// Crash impact walk cadence ($6B2A: reload starts at 2 ticks and
|
|
// INCs each step -- 2,3,4,5 ticks for cels $CE..$D1 at the NTSC
|
|
// 33 ms game tick), then the sprite hides.
|
|
static const uint16_t kDeathStepMs[4] = { 66u, 100u, 133u, 166u };
|
|
#define ST_DEATH_TUMBLE_MS 66u /* $CC/$CD swap every 2 game ticks */
|
|
|
|
// Map direction-mask (CIA1 PortA bits after EOR #$FF: bit0=UP bit1=DOWN
|
|
// bit2=LEFT bit3=RIGHT) to a flameCels[] index, mirroring the C64
|
|
// table at $6DB0. Entries with no flame (no input, or invalid combos
|
|
// like UP+DOWN / LEFT+RIGHT / four-way) return -1.
|
|
static const int8_t kFlameCelByDirMask[16] = {
|
|
-1, /* 0 no input */
|
|
0, /* 1 UP -> $D8 */
|
|
1, /* 2 DOWN -> $D4 */
|
|
-1, /* 3 UP+DOWN invalid */
|
|
2, /* 4 LEFT -> $D5 */
|
|
3, /* 5 UP+LEFT -> $D2 */
|
|
4, /* 6 DOWN+LEFT -> $D1 */
|
|
-1, /* 7 */
|
|
5, /* 8 RIGHT -> $D7 */
|
|
6, /* 9 UP+RIGHT -> $D3 */
|
|
7, /* 10 DOWN+RIGHT -> $D6 */
|
|
-1, -1, -1, -1, -1
|
|
};
|
|
// Stage-5 advance gate: $4A5F CMP #$14 BCC $4A64. Cab row in sprite-Y
|
|
// coords; visible row = sprite-Y - 50, so $14 = 20 = visible row -30.
|
|
#define ST_TITLE_CAB_TAKEOFF_SY 0x14
|
|
// C64-to-visible conversion offsets (sprite hardware borders).
|
|
#define ST_SPRITE_X_OFFSET 24
|
|
#define ST_SPRITE_Y_OFFSET 50
|
|
|
|
typedef struct {
|
|
uint8_t stage; // mirrors gDeathStage during intro
|
|
uint8_t introFrameCount; // mirrors $473F (stage 2 countdown)
|
|
uint8_t decayReload; // mirrors $715E
|
|
uint8_t decayTimer; // mirrors $715D (countdown per tick)
|
|
uint8_t passengerCelParity;// mirrors $7161 (toggled each walk step)
|
|
uint8_t sparkleIdx; // mirrors $716E (sparkle cel index 0..3)
|
|
uint8_t passengerPtr; // mirrors gSpr1PtrShadow $7198
|
|
uint8_t cabPtr; // mirrors gSpr0PtrShadow $7197
|
|
// Sprite positions in C64 sprite-X / sprite-Y space (16-bit because
|
|
// sprite-X is 9 bits when MSB latched). Convert with the OFFSET
|
|
// constants above when rendering.
|
|
int16_t cabSx;
|
|
int16_t cabSy;
|
|
int16_t passengerSx;
|
|
int16_t passengerSy;
|
|
bool passengerVisible;
|
|
bool cabVisible;
|
|
bool flameVisible; // sprite 2 from $6D6A flameSpriteUpdate
|
|
uint8_t flameDirMask; // mirrors $716A passed into $6DB0,X
|
|
// Demo (post-intro stage 6) playback state -- script from $0902.
|
|
// Logo flip-book + color cycle. The "SPACE TAXI" logo is 103 cells
|
|
// of char $84; the C64 ($4827) copies one of the flip frames
|
|
// $85..$88 over char $84's bitmap every 4th frame, walking the
|
|
// ping-pong table $48A7 = {1,2,3,4,3,2} so each dot rotates end over
|
|
// end, and advances the 8-color cycle $489C when the index hits 4.
|
|
uint8_t logoFlipDiv; // mirrors $48A6 (mod-4 tick divider)
|
|
uint8_t logoFlipIdx; // mirrors $48A5 (0..5 ping-pong index)
|
|
uint8_t logoColorIdx; // mirrors $48A4 (0..7 color cycle index)
|
|
bool logoNeedsPaint; // flip/color changed (or tilemap repainted)
|
|
} StTitleStateT;
|
|
|
|
// The single animated logo character. Its bitmap is flip-booked through
|
|
// the four frames that follow it in the charset ($85..$88).
|
|
#define ST_LOGO_CHAR 0x84u
|
|
|
|
static StTitleStateT gTitle;
|
|
|
|
static void titleReset(void);
|
|
static void titleTick(void);
|
|
|
|
|
|
// Public so stLevel.c can ask "give me the tile object for index N".
|
|
const jlTileT *stRenderTileForIndex(uint8_t tileIdx) {
|
|
if (!gRender.tileValid[tileIdx]) {
|
|
return NULL;
|
|
}
|
|
return &gRender.tiles[tileIdx];
|
|
}
|
|
|
|
|
|
// Default 16-entry palette: matches the C64 VIC-II color register
|
|
// order so that an asset extracted with the C64 palette (via the
|
|
// extractFromDump.py tool) renders with the right colors here. The
|
|
// JoeyLib value format is $0RGB (4 bits per channel, top nibble
|
|
// unused). Index 0 black, 1 white, then C64 standard order.
|
|
static const uint16_t kDefaultPalette[16] = {
|
|
0x0000, // 0 black
|
|
0x0FFF, // 1 white
|
|
0x0833, // 2 red
|
|
0x06BB, // 3 cyan
|
|
0x0839, // 4 purple
|
|
0x05A4, // 5 green
|
|
0x0438, // 6 blue
|
|
0x0BC7, // 7 yellow
|
|
0x0852, // 8 orange
|
|
0x0540, // 9 brown
|
|
0x0B66, // 10 light red
|
|
0x0555, // 11 dark gray
|
|
0x0777, // 12 mid gray
|
|
0x09E8, // 13 light green
|
|
0x076C, // 14 light blue
|
|
0x09AA // 15 light gray
|
|
};
|
|
|
|
// Tile-index range -> placeholder fill color (used when no tile bank
|
|
// is loaded). Matches the engine's index-range convention.
|
|
static uint8_t placeholderColorFor(uint8_t tileIdx, uint8_t bgColor) {
|
|
// Used only when no tile bank is loaded -- debug-rendering. Slot
|
|
// indices reference kDefaultPalette above.
|
|
if (tileIdx == 0u) { return bgColor; }
|
|
if (tileIdx < 64u) { return 6u; } // walls -> blue
|
|
if (tileIdx < 128u) { return 5u; } // pads -> green
|
|
return 14u; // decor -> light blue
|
|
}
|
|
|
|
|
|
void stRenderInit(jlSurfaceT *stage) {
|
|
memset(&gRender, 0, sizeof(gRender));
|
|
gRender.taxiBackup.bytes = gRender.taxiBackupMem;
|
|
gRender.flameBackup.bytes = gRender.flameBackupMem;
|
|
gRender.currentBankId = 0xFFu; // no bank loaded yet
|
|
for (uint8_t i = 0u; i < ST_MAX_PASSENGERS; i++) {
|
|
gRender.passengerBackup[i].bytes = gRender.passengerBackupMem[i];
|
|
}
|
|
|
|
// Install the default 16-color palette on slot 0 and route the
|
|
// whole screen through it. Subsequent asset palettes can override
|
|
// by writing into slot 1+ via jlPaletteSet / jlScbSetRange.
|
|
jlPaletteSet(stage, 0u, kDefaultPalette);
|
|
jlScbSetRange(stage, 0u, (uint16_t)(SURFACE_HEIGHT - 1u), 0u);
|
|
|
|
// Startup feedback: the IIgs spends several seconds streaming the
|
|
// banks and compiling sprite cels to their asm routines, all of
|
|
// which used to happen against a black screen. Show a growing bar
|
|
// immediately; once the font is in, add the LOADING caption. The
|
|
// font loads FIRST because text needs its glyph surface.
|
|
jlSurfaceClear(stage, 0u);
|
|
loadProgress(stage, 0u);
|
|
if (!loadFontSheet(stage)) {
|
|
jlLogF("stRender: ! font load failed (%s)", ST_FONT_PATH);
|
|
}
|
|
buildAsciiMap();
|
|
loadProgress(stage, 1u);
|
|
|
|
// Tile bank is loaded on demand in stRenderLevel (per-level).
|
|
if (!loadSpriteSheet(stage)) {
|
|
jlLogF("stRender: ! sprite sheet load failed (%s)", ST_SPRITE_SHEET_PATH);
|
|
}
|
|
titleReset();
|
|
}
|
|
|
|
|
|
bool stRenderTitleIntroDone(void) {
|
|
return gTitle.stage == ST_TITLE_STAGE_DONE;
|
|
}
|
|
|
|
|
|
// Advance the title intro by ONE game tick. The main loop calls this
|
|
// on the fixed 30 Hz accumulator (not once per rendered frame) so the
|
|
// sparkle / walk / takeoff sequence and the logo flip run at the C64's
|
|
// rate on every port -- identical pre-game timing everywhere.
|
|
void stRenderTitleAdvance(void) {
|
|
titleTick();
|
|
}
|
|
|
|
|
|
// Restart the title intro (sparkle -> walk -> takeoff). Called on
|
|
// every return to the title so the attract cycle replays -- the C64
|
|
// re-enters the intro the same way after a demo exits.
|
|
void stRenderTitleReset(void) {
|
|
titleReset();
|
|
}
|
|
|
|
|
|
void stRenderShutdown(void) {
|
|
destroySprites();
|
|
if (gRender.fontSurface != NULL) {
|
|
jlSurfaceDestroy(gRender.fontSurface);
|
|
gRender.fontSurface = NULL;
|
|
}
|
|
}
|
|
|
|
|
|
void stRenderDrawText(jlSurfaceT *stage, uint8_t bx, uint8_t by, const char *s) {
|
|
if (!gRender.fontReady || gRender.fontSurface == NULL || s == NULL) {
|
|
return;
|
|
}
|
|
jlDrawText(stage, bx, by, gRender.fontSurface, gRender.asciiMap, s);
|
|
}
|
|
|
|
|
|
void stRenderLevel(jlSurfaceT *stage, const StLevelT *level) {
|
|
const jlTileT *tile;
|
|
uint8_t bx;
|
|
uint8_t by;
|
|
uint8_t tileIdx;
|
|
size_t i;
|
|
|
|
// Swap to the level's tile bank if it's not already loaded.
|
|
// First call lands here and pulls tbank<id>.tbk off disk.
|
|
if (!gRender.bankLoaded || gRender.currentBankId != level->tileBankId) {
|
|
(void)loadTileBank(level->tileBankId);
|
|
gRender.tilemapDirty = true;
|
|
}
|
|
// Scene change detected -> force a repaint. Otherwise this is a
|
|
// no-op (the level tilemap data is static between calls).
|
|
if (gRender.lastLevel != level) {
|
|
gRender.tilemapDirty = true;
|
|
gRender.lastLevel = level;
|
|
}
|
|
if (!gRender.tilemapDirty) {
|
|
return;
|
|
}
|
|
gRender.tilemapDirty = false;
|
|
|
|
jlSurfaceClear(stage, level->bgColor);
|
|
|
|
for (by = 0u; by < ST_PLAYFIELD_ROWS; by++) {
|
|
for (bx = 0u; bx < ST_TILEMAP_W; bx++) {
|
|
i = (size_t)by * ST_TILEMAP_W + bx;
|
|
tileIdx = level->tilemap[i];
|
|
// tileIdx is uint8_t, naturally bounded to ST_TILE_BANK_MAX=256.
|
|
tile = gRender.tileValid[tileIdx] ? &gRender.tiles[tileIdx] : NULL;
|
|
if (tile != NULL) {
|
|
// C64-style per-cell coloring: tile bitmap is a fixed
|
|
// glyph from the charset, colormap[i] is the foreground
|
|
// color from color RAM ($D800). Empty tile (idx 0)
|
|
// still goes through here as bgColor on both fg/bg --
|
|
// jlTileFill is left for the no-bank-loaded fallback.
|
|
uint8_t fg = (uint8_t)(level->colormap[i] & 0x0Fu);
|
|
jlTilePasteMono(stage, bx, by, tile, fg, level->bgColor);
|
|
} else {
|
|
jlTileFill(stage, bx, by, placeholderColorFor(tileIdx, level->bgColor));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
void stRenderLevelChanged(void) {
|
|
// Force the next stRenderLevel call to repaint. Needed because the
|
|
// dirty-cache compares lastLevel by pointer, but stLevelLoad
|
|
// overwrites *game.level in place -- the pointer stays equal and
|
|
// the cache would otherwise skip the new tilemap.
|
|
gRender.tilemapDirty = true;
|
|
gRender.lastLevel = NULL;
|
|
}
|
|
|
|
|
|
|
|
// $4A03 dispatcher entry state, from $47C9 postInitPressFire:
|
|
// $47C7: A9 02 LDA #$02
|
|
// $47C9: 8D 63 71 STA $7163 ; gDeathStage = 2
|
|
// $47CC: A9 5A LDA #$5A
|
|
// $47CE: 8D 3F 47 STA $473F ; gIntroFrameCount
|
|
// $47D1: A9 00 LDA #$00
|
|
// $47D3: 8D 60 71 STA $7160 ; gPadHoverXFrac
|
|
// $47D6: A9 28 LDA #$28
|
|
// $47D8: 8D 5F 71 STA $715F ; gPadHoverXCol
|
|
// $47DB: AD 5E 71 LDA $715E ; decayReload (snapshot $03)
|
|
// $47DE: 8D 5D 71 STA $715D ; decayTimer
|
|
// Sprite positions from the $49BC table-loop, X=0..7:
|
|
// $4994 col[] = 28 32 00 86 9C A2 BA D4
|
|
// $499C frac[] = 00 01 00 00 00 00 00 00
|
|
// $49AC row[] = 84 84 00 E2 E2 E2 E2 E2
|
|
// $49B4 ptr[] = C1 C7 00 DB DE DF E0 E1
|
|
// Sprite 0 (cab) sprite-X = $28 = 40, sprite-Y = $84, ptr $C1.
|
|
// Sprite 1 (passenger) sprite-X = $32 + $100 = 306, sprite-Y = $84,
|
|
// ptr $C7. (frac=1 latches the X-MSB.)
|
|
static void titleReset(void) {
|
|
memset(&gTitle, 0, sizeof(gTitle));
|
|
gTitle.stage = ST_TITLE_STAGE_SPARKLE;
|
|
gTitle.introFrameCount = ST_TITLE_SPARKLE_FRAMES;
|
|
gTitle.decayReload = ST_TITLE_WALK_TICK_RELOAD;
|
|
gTitle.decayTimer = ST_TITLE_WALK_TICK_RELOAD;
|
|
gTitle.passengerCelParity = 0u;
|
|
gTitle.passengerPtr = ST_TITLE_PASS_PTR_INIT;
|
|
gTitle.cabPtr = 0xC1; // sprite 0 init ptr
|
|
gTitle.cabSx = ST_TITLE_CAB_SX_INIT;
|
|
gTitle.cabSy = ST_TITLE_CAB_SY_INIT;
|
|
gTitle.passengerSx = ST_TITLE_PASS_SX_INIT;
|
|
gTitle.passengerSy = ST_TITLE_PASS_SY_INIT;
|
|
gTitle.cabVisible = true;
|
|
gTitle.passengerVisible = true;
|
|
gTitle.flameVisible = false;
|
|
// Force the first tick to be an animation step (3 -> 0 on first ++)
|
|
// AND force a logo paint on the first render frame so the flip
|
|
// frame + color appear immediately (the tilemap was just repainted
|
|
// by stRenderLevelChanged, erasing any prior logo cells).
|
|
gTitle.logoFlipDiv = 3u;
|
|
gTitle.logoNeedsPaint = true;
|
|
}
|
|
|
|
|
|
// $4827 logo flip-book + $4861 color cycle. The title's "SPACE TAXI"
|
|
// logo is 103 cells of char $84 (verified in the title dump). Every 4th
|
|
// frame ($4827: INC $48A6 / AND #$03 / BEQ) the C64 copies one flip
|
|
// frame ($85..$88) over char $84's bitmap, walking the ping-pong table
|
|
// $48A7 = {1,2,3,4,3,2} -> frames $85,$86,$87,$88,$87,$86 so each dot
|
|
// rotates end over end; when the index reaches 4 it advances the 8-entry
|
|
// color cycle $489C = {red,orange,yellow,green,blue,lt-blue,cyan,purple}
|
|
// (C64 codes, which are this port's palette indices). We reproduce it by
|
|
// re-pasting every $84 cell with the current flip-frame tile + color.
|
|
// Flip/color advance ($4827 divider + $484D/$4853 index+color). Runs
|
|
// once per GAME TICK (called from titleTick), so the flip-book steps
|
|
// every 4 game ticks -- the C64's $48A6 mod-4 gate at the 30 Hz main-
|
|
// loop rate, frame-rate-independent. Sets logoNeedsPaint when it steps;
|
|
// the actual re-paste is deferred to titlePaintLogo in the render pass.
|
|
static void titleAdvanceLogo(void) {
|
|
gTitle.logoFlipDiv = (uint8_t)((gTitle.logoFlipDiv + 1u) & 3u);
|
|
if (gTitle.logoFlipDiv != 0u) {
|
|
return;
|
|
}
|
|
gTitle.logoFlipIdx++;
|
|
if (gTitle.logoFlipIdx == 6u) {
|
|
gTitle.logoFlipIdx = 0u;
|
|
}
|
|
if (gTitle.logoFlipIdx == 4u) {
|
|
gTitle.logoColorIdx = (uint8_t)((gTitle.logoColorIdx + 1u) & 7u);
|
|
}
|
|
gTitle.logoNeedsPaint = true;
|
|
}
|
|
|
|
|
|
// Re-paste every $84 logo cell with the current flip-frame tile + color
|
|
// ($489C palette codes = the port's indices). Render-pass only, and only
|
|
// when the flip advanced or the tilemap was just repainted -- between
|
|
// steps the logo cells sit static on the tilemap.
|
|
static void titlePaintLogo(jlSurfaceT *stage, const StLevelT *level) {
|
|
static const uint8_t kFlip[6] = { 1u, 2u, 3u, 4u, 3u, 2u }; // $48A7
|
|
static const uint8_t kColor[8] = { 0x02u, 0x08u, 0x07u, 0x05u,
|
|
0x06u, 0x0Eu, 0x03u, 0x04u }; // $489C
|
|
const jlTileT *tile;
|
|
uint8_t ch;
|
|
uint8_t fg;
|
|
uint8_t bx;
|
|
uint8_t by;
|
|
size_t i;
|
|
|
|
if (!gTitle.logoNeedsPaint) {
|
|
return;
|
|
}
|
|
gTitle.logoNeedsPaint = false;
|
|
|
|
ch = (uint8_t)(ST_LOGO_CHAR + kFlip[gTitle.logoFlipIdx]);
|
|
fg = kColor[gTitle.logoColorIdx];
|
|
tile = gRender.tileValid[ch] ? &gRender.tiles[ch] : NULL;
|
|
if (tile != NULL) {
|
|
for (by = 0u; by < ST_PLAYFIELD_ROWS; by++) {
|
|
for (bx = 0u; bx < ST_TILEMAP_W; bx++) {
|
|
i = (size_t)by * ST_TILEMAP_W + bx;
|
|
if (level->tilemap[i] == ST_LOGO_CHAR) {
|
|
jlTilePasteMono(stage, bx, by, tile, fg, level->bgColor);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// $6D0D passenger-walk: toggle $7161 cel parity, compute sign of
|
|
// (passenger_X - pad_hover_X), branch to walk-LEFT or walk-RIGHT
|
|
// helper. Both load gSpr1PtrShadow from a 2-entry ptr table indexed
|
|
// by the cel-parity bit, then advance the column by +/- 2.
|
|
// $6D30 LDX $7161 / LDA $6D66,X / STA $7198 (walk RIGHT)
|
|
// $6D33: $6D66 = $C2, $6D67 = $C3
|
|
// $6D4B LDX $7161 / LDA $6D68,X / STA $7198 (walk LEFT)
|
|
// $6D4E: $6D68 = $C4, $6D69 = $C5
|
|
// In the title intro the passenger starts at sprite-X 306 and walks
|
|
// LEFT toward $28, so the LEFT cel table ($C4 / $C5) is the one in
|
|
// use. We track the live ptr in gTitle.passengerPtr so the boarding
|
|
// stage can keep incrementing from wherever the walk left off.
|
|
static void titleStepPassenger(void) {
|
|
gTitle.passengerCelParity ^= 1u;
|
|
if (gTitle.passengerSx > (int16_t)ST_TITLE_PAD_HOVER_SX) {
|
|
gTitle.passengerSx -= 2;
|
|
gTitle.passengerPtr = (gTitle.passengerCelParity & 1u)
|
|
? ST_TITLE_PASS_PTR_WALK_HI
|
|
: ST_TITLE_PASS_PTR_WALK_LO;
|
|
} else if (gTitle.passengerSx < (int16_t)ST_TITLE_PAD_HOVER_SX) {
|
|
gTitle.passengerSx += 2;
|
|
// Walk-RIGHT path uses $C2/$C3; not used by the title, but
|
|
// include it for completeness so any side-entry passenger
|
|
// would render correctly.
|
|
gTitle.passengerPtr = (gTitle.passengerCelParity & 1u)
|
|
? 0xC3
|
|
: 0xC2;
|
|
}
|
|
}
|
|
|
|
|
|
// $4A03 dispatcher. One iteration per host frame, exactly as the C64
|
|
// runs $4A03 from $47E4 each iteration of the title intro loop.
|
|
static void titleTick(void) {
|
|
// Logo flip-book + color cycle advance once per game tick (its own
|
|
// mod-4 divider gates the actual step), independent of the intro
|
|
// stage machine below.
|
|
titleAdvanceLogo();
|
|
|
|
switch (gTitle.stage) {
|
|
case ST_TITLE_STAGE_SPARKLE:
|
|
// $4A17: JSR $66B7 / DEC $473F / BEQ -> INC $7163.
|
|
// $66B7 logic (per the asm):
|
|
// DEC $715D ; gIntroDecayTimer
|
|
// BEQ $66BD ; hit-zero -> cycle a cel
|
|
// RTS ; else keep current cel
|
|
// $66BD: LDA $715E STA $715D ; reload from gIntroDecayReload
|
|
// INC $716E ; sparkleIdx
|
|
// LDA $716E AND #$03 STA $716E ; mask 0..3
|
|
// TAX
|
|
// LDA $66D6,X STA $7198 ; passenger ptr = sparkle table
|
|
// So the passenger ptr cycles $C6 / $C7 / $D9 / $C7 every reload
|
|
// frames during the 90-frame sparkle wait, AND the decay timer
|
|
// is left in the middle of its countdown when stage 3 takes
|
|
// over -- which is why the walk starts after 0-2 frames, not
|
|
// the full 3-frame reload.
|
|
if (gTitle.decayTimer > 0u) {
|
|
gTitle.decayTimer--;
|
|
}
|
|
if (gTitle.decayTimer == 0u) {
|
|
gTitle.decayTimer = gTitle.decayReload;
|
|
gTitle.sparkleIdx = (uint8_t)((gTitle.sparkleIdx + 1u) & 0x03u);
|
|
gTitle.passengerPtr = kPassengerWaveCels[gTitle.sparkleIdx];
|
|
}
|
|
if (gTitle.introFrameCount > 0u) {
|
|
gTitle.introFrameCount--;
|
|
}
|
|
if (gTitle.introFrameCount == 0u) {
|
|
gTitle.stage = ST_TITLE_STAGE_WALK;
|
|
}
|
|
break;
|
|
|
|
case ST_TITLE_STAGE_WALK:
|
|
// $4A24: DEC $715D / BEQ -> reload from $715E + JSR $6D0D /
|
|
// then check (passenger col == $28) && ($7186 == 0) -> advance.
|
|
if (gTitle.decayTimer > 0u) {
|
|
gTitle.decayTimer--;
|
|
}
|
|
if (gTitle.decayTimer == 0u) {
|
|
gTitle.decayTimer = gTitle.decayReload;
|
|
titleStepPassenger();
|
|
if (gTitle.passengerSx == (int16_t)ST_TITLE_PAD_HOVER_SX) {
|
|
gTitle.stage = ST_TITLE_STAGE_HANDOFF;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case ST_TITLE_STAGE_HANDOFF:
|
|
// $4A45: JMP $67A6.
|
|
// $67A6: DEC $715D / BEQ -> reload + INC $7198 (passenger
|
|
// sprite cel ptr). Advance when ptr == $CC.
|
|
if (gTitle.decayTimer > 0u) {
|
|
gTitle.decayTimer--;
|
|
}
|
|
if (gTitle.decayTimer == 0u) {
|
|
gTitle.decayTimer = gTitle.decayReload;
|
|
gTitle.passengerPtr++;
|
|
if (gTitle.passengerPtr >= ST_TITLE_PASS_PTR_BOARDED) {
|
|
// $67BD: STA $718F = 0 (pad hover off); INC $7163 to 5.
|
|
gTitle.passengerVisible = false;
|
|
gTitle.stage = ST_TITLE_STAGE_LIFTOFF;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case ST_TITLE_STAGE_LIFTOFF:
|
|
// $4A48: LDX #0; LDA #$FE; JSR $4113 (gTaxiRow[0] += -2).
|
|
// LDA #$01; STA gInputDirMask. LDA #$C0; STA gSpr0Ptr.
|
|
// JSR $6D6A (flame sprite update -- sprite 2 visible).
|
|
// LDA gTaxiRow[0]; CMP #$14; BCC $4A64.
|
|
gTitle.cabSy -= 2;
|
|
gTitle.cabPtr = 0xC0;
|
|
gTitle.flameDirMask = 0x01; // UP only
|
|
gTitle.flameVisible = true;
|
|
if (gTitle.cabSy < (int16_t)ST_TITLE_CAB_TAKEOFF_SY) {
|
|
// $4A64-$4A6C: INC gDeathStage; silence voice 3.
|
|
gTitle.cabVisible = false;
|
|
gTitle.flameVisible = false;
|
|
// $4A64 INCs gDeathStage out of the intro: on the C64 the
|
|
// machine now enters the rolling attract demo. The port
|
|
// signals spacetaxi.c via stRenderTitleIntroDone().
|
|
gTitle.stage = ST_TITLE_STAGE_DONE;
|
|
}
|
|
break;
|
|
|
|
case ST_TITLE_STAGE_DONE:
|
|
default:
|
|
// Intro finished -- the title art sits static (logo cycle
|
|
// continues); spacetaxi.c switches to ST_STATE_DEMO.
|
|
break;
|
|
}
|
|
}
|
|
|
|
|
|
void stRenderFrame(jlSurfaceT *stage, const StGameT *game) {
|
|
int16_t px;
|
|
int16_t py;
|
|
uint8_t taxiCel;
|
|
uint8_t i;
|
|
char buf[32];
|
|
|
|
// Title screen: render the title tilemap (extracted from the C64
|
|
// game's screen RAM at $0400). The "SPACE TAXI" logo is animated by
|
|
// titleAnimateLogo below -- the C64 flip-books char $84's bitmap
|
|
// ($4827) so each dot rotates end over end while an 8-color cycle
|
|
// ($4861) runs; both were missing before.
|
|
if (game->state == ST_STATE_TITLE) {
|
|
// Restore the area saved under the prev-frame sprites BEFORE
|
|
// anything else paints. Without this, jlSpriteDraw leaves the
|
|
// old sprite pixels on the stage and the cab/passenger smear
|
|
// across the screen as they move ("5 cabs then erase" was the
|
|
// titleCycleTick periodic tilemap re-blit fighting the
|
|
// accumulated trails).
|
|
// C64 sprite priority: sprite 0 (cab) > 1 (passenger) > 2 (flame).
|
|
// Draw order each frame: flame, passenger, cab (lowest priority
|
|
// first). Restore is the reverse: cab, passenger, flame -- so
|
|
// overlapping saved underlays peel off in the right sequence.
|
|
if (gRender.taxiHasBackup) {
|
|
jlSpriteRestoreUnder(stage, &gRender.taxiBackup);
|
|
gRender.taxiHasBackup = false;
|
|
}
|
|
if (gRender.passengerHasBackup[0]) {
|
|
jlSpriteRestoreUnder(stage, &gRender.passengerBackup[0]);
|
|
gRender.passengerHasBackup[0] = false;
|
|
}
|
|
if (gRender.flameHasBackup) {
|
|
jlSpriteRestoreUnder(stage, &gRender.flameBackup);
|
|
gRender.flameHasBackup = false;
|
|
}
|
|
// stRenderLevel only re-paints the tilemap when lastLevel changed
|
|
// (e.g., transitioned from gameplay back to title) -- otherwise
|
|
// it's a fast no-op.
|
|
stRenderLevel(stage, &game->level);
|
|
// Paint the "SPACE TAXI" logo (char $84 cells) at its current
|
|
// flip frame + color over the freshly-painted tilemap, BEFORE
|
|
// the sprites save-under and draw so they overlay it. The flip
|
|
// ADVANCE happens on the game-tick clock (titleAdvanceLogo);
|
|
// this only re-pastes when it changed or the tilemap repainted.
|
|
titlePaintLogo(stage, &game->level);
|
|
// No "fares per game" overlay on the title: the C64 has a
|
|
// separate options/menu scene driven by $5295 (header text)
|
|
// and $52E1 / $533C (the "1 2 3 4" digit row at row 2 col 28
|
|
// with the selected digit highlighted in color RAM at $D86C).
|
|
// The main title's screen RAM at $0400 contains no game-option
|
|
// text -- only "BY JOHN F. BUTCHER" credits and the
|
|
// UP=hiscore / DOWN=instructions / FIRE=begin joystick line.
|
|
// Options-menu scene is a separate state to be built later.
|
|
// The intro state machine (sparkle -> passenger walk -> taxi
|
|
// takeoff) is advanced on the fixed game-tick clock in the main
|
|
// loop (stRenderTitleAdvance), not here -- this pass only DRAWS
|
|
// whichever sprites the current intro state has visible.
|
|
// sprite-X -> visible col = sprite-X - 24 (C64 border offset).
|
|
// sprite-Y -> visible row = sprite-Y - 50 (top border).
|
|
// Draw lowest priority first: flame (sprite 2), then passenger
|
|
// (sprite 1), then cab (sprite 0) on top.
|
|
if (gTitle.flameVisible && flameStrobeOn() &&
|
|
gTitle.flameDirMask < 16u) {
|
|
int8_t cel = kFlameCelByDirMask[gTitle.flameDirMask];
|
|
if (cel >= 0 && gRender.flameCels[cel] != NULL) {
|
|
// $6D74-$6D82: flame sprite-X = cab sprite-X - 2.
|
|
// $6D85-$6D88: flame sprite-Y = cab sprite-Y.
|
|
int16_t fx = (int16_t)(gTitle.cabSx - 2 - ST_SPRITE_X_OFFSET);
|
|
int16_t fy = (int16_t)(gTitle.cabSy - ST_SPRITE_Y_OFFSET);
|
|
jlSpriteSaveAndDraw(stage, gRender.flameCels[cel],
|
|
fx, fy, &gRender.flameBackup);
|
|
gRender.flameHasBackup = true;
|
|
}
|
|
}
|
|
if (gTitle.passengerVisible) {
|
|
int16_t cel = passCelForPtr(gTitle.passengerPtr);
|
|
if (cel < 0) {
|
|
cel = 0;
|
|
}
|
|
if (cel >= ST_PASSENGER_CEL_COUNT) {
|
|
cel = ST_PASSENGER_CEL_COUNT - 1;
|
|
}
|
|
if (gRender.passengerCels[cel] != NULL) {
|
|
jlSpriteSaveAndDraw(stage, gRender.passengerCels[cel],
|
|
(int16_t)(gTitle.passengerSx - ST_SPRITE_X_OFFSET),
|
|
(int16_t)(gTitle.passengerSy - ST_SPRITE_Y_OFFSET),
|
|
&gRender.passengerBackup[0]);
|
|
gRender.passengerHasBackup[0] = true;
|
|
}
|
|
}
|
|
if (gTitle.cabVisible) {
|
|
// $4A54 sets gSpr0PtrShadow = $C0 (cab with landing gear
|
|
// RETRACTED) during takeoff. Stages 2-4 use the init ptr
|
|
// $C1 (gear extended). We track the live ptr in
|
|
// gTitle.cabPtr and map: $C1 -> cel 0, $C0 -> cel 1.
|
|
int16_t cel = (gTitle.cabPtr == 0xC0) ? 1 : 0;
|
|
if (gRender.taxiCels[cel] == NULL) {
|
|
cel = 0;
|
|
}
|
|
if (gRender.taxiCels[cel] != NULL) {
|
|
jlSpriteSaveAndDraw(stage, gRender.taxiCels[cel],
|
|
(int16_t)(gTitle.cabSx - ST_SPRITE_X_OFFSET),
|
|
(int16_t)(gTitle.cabSy - ST_SPRITE_Y_OFFSET),
|
|
&gRender.taxiBackup);
|
|
gRender.taxiHasBackup = true;
|
|
}
|
|
}
|
|
jlWaitVBL();
|
|
jlStagePresent();
|
|
return;
|
|
}
|
|
if (game->state == ST_STATE_HIGHSCORES) {
|
|
// "THE IMMORTAL CABBIES" high-score screen ($4C1F). Header +
|
|
// the game's default 8-entry table, transcribed from $4A89
|
|
// (score string + 15-char name). Persistent scoring isn't wired
|
|
// up, so we show the original default table.
|
|
static const char *kHighScores[8] = {
|
|
"3877.56 MICHAEL PLATE",
|
|
"1809.51 MICHAEL PLATE",
|
|
"1179.87 ANDREAS PLATE",
|
|
" 892.65 MICHAEL PLATE",
|
|
" 685.37 ANDREAS PLATE",
|
|
" 629.45 MICHAEL PLATE",
|
|
" 569.50 MICHAEL PLATE",
|
|
" 504.97 ANDREAS PLATE",
|
|
};
|
|
uint8_t i;
|
|
jlSurfaceClear(stage, 0u);
|
|
stRenderDrawText(stage, 10u, 1u, "THE IMMORTAL CABBIES");
|
|
for (i = 0u; i < 8u; i++) {
|
|
stRenderDrawText(stage, 9u, (uint8_t)(4u + i * 2u), kHighScores[i]);
|
|
}
|
|
stRenderDrawText(stage, 10u, 22u, "PRESS FIRE TO RETURN");
|
|
jlWaitVBL();
|
|
jlStagePresent();
|
|
return;
|
|
}
|
|
if (game->state == ST_STATE_INSTRUCTIONS) {
|
|
// Author/about screen ($556A, reached via DOWN). Text transcribed
|
|
// verbatim from $56D4-$57A6.
|
|
jlSurfaceClear(stage, 0u);
|
|
stRenderDrawText(stage, 1u, 3u, "THIS PROGRAM DEVELOPED AND WRITTEN BY");
|
|
stRenderDrawText(stage, 12u, 5u, "JOHN F. KUTCHER");
|
|
stRenderDrawText(stage, 15u, 7u, "11/21/65");
|
|
stRenderDrawText(stage, 4u, 10u, "CURRENTLY, AS OF JANUARY 1984,");
|
|
stRenderDrawText(stage, 5u, 12u, "HE IS ATTENDING JOHNS HOPKINS");
|
|
stRenderDrawText(stage, 6u, 14u, "UNIVERSITY IN BALTIMORE, MD");
|
|
stRenderDrawText(stage, 1u, 18u, "ALSO TRY RESCUE SQUAD BY JOHN KUTCHER");
|
|
stRenderDrawText(stage, 10u, 22u, "PRESS FIRE TO RETURN");
|
|
jlWaitVBL();
|
|
jlStagePresent();
|
|
return;
|
|
}
|
|
if (game->state == ST_STATE_OPTIONS) {
|
|
// "GAME VARIATIONS" menu ($5295). Text + positions transcribed
|
|
// from the C64: header (13,0), prompt (1,2), digits (28,2),
|
|
// instructions (5,4) and (19,5). The C64 recolors the selected
|
|
// digit via color RAM ($D86C + sel*2); we mark it with a
|
|
// highlight box behind the digit cell (row 2, col 28 + sel*2).
|
|
uint8_t sel = (uint8_t)((game->fareTarget - 1u) & 3u); // 0..3
|
|
int16_t hx = (int16_t)((28 + (int)sel * 2) * (int)ST_TILE_PIXELS);
|
|
jlSurfaceClear(stage, 0u);
|
|
jlFillRect(stage, hx, (int16_t)(2 * ST_TILE_PIXELS),
|
|
(int16_t)ST_TILE_PIXELS, (int16_t)ST_TILE_PIXELS, 6u);
|
|
stRenderDrawText(stage, 13u, 0u, "GAME VARIATIONS");
|
|
stRenderDrawText(stage, 1u, 2u, "SELECT NUMBER OF CABBIES:");
|
|
stRenderDrawText(stage, 28u, 2u, "1 2 3 4");
|
|
stRenderDrawText(stage, 5u, 4u, "USE JOYSTICK: LEFT OR RIGHT,");
|
|
stRenderDrawText(stage, 19u, 5u, "FIRE TO SELECT");
|
|
jlWaitVBL();
|
|
jlStagePresent();
|
|
return;
|
|
}
|
|
if (game->state == ST_STATE_GAME_OVER) {
|
|
jlSurfaceClear(stage, 0u);
|
|
stRenderDrawText(stage, 15u, 8u, "GAME OVER");
|
|
snprintf(buf, sizeof(buf), "FINAL SCORE %06lu",
|
|
(unsigned long)game->score);
|
|
stRenderDrawText(stage, 12u, 11u, buf);
|
|
stRenderDrawText(stage, 9u, 16u, "PRESS SPACE TO RESTART");
|
|
jlWaitVBL();
|
|
jlStagePresent();
|
|
return;
|
|
}
|
|
|
|
// Restore prev-frame sprite backups FIRST so the static tilemap
|
|
// shows through where the taxi/passenger/flame used to be. Then
|
|
// we'll save+draw at the new positions below. LIFO order: the cab
|
|
// was saved AFTER the flame (its backup holds flame pixels), so
|
|
// the cab restores first and the flame restore then cleans the
|
|
// re-deposited flame pixels.
|
|
if (gRender.taxiHasBackup) {
|
|
jlSpriteRestoreUnder(stage, &gRender.taxiBackup);
|
|
gRender.taxiHasBackup = false;
|
|
}
|
|
if (gRender.flameHasBackup) {
|
|
jlSpriteRestoreUnder(stage, &gRender.flameBackup);
|
|
gRender.flameHasBackup = false;
|
|
}
|
|
for (i = 0u; i < ST_MAX_PASSENGERS; i++) {
|
|
if (gRender.passengerHasBackup[i]) {
|
|
jlSpriteRestoreUnder(stage, &gRender.passengerBackup[i]);
|
|
gRender.passengerHasBackup[i] = false;
|
|
}
|
|
}
|
|
// Static tilemap commit (no-op after first frame in a scene).
|
|
stRenderLevel(stage, &game->level);
|
|
|
|
// Re-draw HUD strip (cheap; just a few characters per cycle).
|
|
stHudDraw(stage, game);
|
|
|
|
px = (int16_t)(game->taxi.x >> ST_SUBPIXEL_SHIFT);
|
|
py = (int16_t)(game->taxi.y >> ST_SUBPIXEL_SHIFT);
|
|
|
|
// $619B cel select: facing picks the $C0/$C1 (right) or $DC/$DD
|
|
// (left) pair; bit 0 is the LANDING GEAR state. The body is a
|
|
// STATIC cel -- no flicker of any kind. Sheet order
|
|
// [$C1,$C0,$DC,$DD]:
|
|
// right: ptr = $C0|gear -> cel 1 - gear
|
|
// left: ptr = $DC|gear -> cel 2 + gear
|
|
// (The crash anim uses the dedicated death cels below.)
|
|
if (game->taxi.facing == ST_DIR_LEFT) {
|
|
taxiCel = (uint8_t)(game->taxi.gearDown ? 3u : 2u);
|
|
} else {
|
|
taxiCel = (uint8_t)(game->taxi.gearDown ? 0u : 1u);
|
|
}
|
|
if (gRender.taxiCels[taxiCel] == NULL) {
|
|
taxiCel = 0u;
|
|
}
|
|
// Engine flame: C64 sprite 2 ($6D6A) at (cab_x - 2, cab_y),
|
|
// cel indexed by the $716A direction mask through the $6DB0
|
|
// table, drawn on alternate frames only (the parity flicker),
|
|
// hidden when no input is held. Drawn BEFORE the cab so the cab
|
|
// (sprite 0) keeps VIC priority on top.
|
|
if (game->taxi.warpFrame == 0u && game->taxi.thrusting &&
|
|
!game->taxi.landed && flameStrobeOn() &&
|
|
game->taxi.dirMask < 16u) {
|
|
int8_t fcel = kFlameCelByDirMask[game->taxi.dirMask];
|
|
if (fcel >= 0 && gRender.flameCels[fcel] != NULL) {
|
|
jlSpriteSaveAndDraw(stage, gRender.flameCels[fcel],
|
|
(int16_t)(px - 2), py,
|
|
&gRender.flameBackup);
|
|
gRender.flameHasBackup = true;
|
|
}
|
|
}
|
|
if (game->taxi.crashTicks > 0u) {
|
|
// Crash death: phase 1 tumbles the $CC/$CD debris pair while
|
|
// falling; the floor hit ($6ACC row $DA) starts the $CE..$D1
|
|
// impact walk at the slowing $6B2A cadence, then the wreck
|
|
// hides until respawn.
|
|
uint32_t nowMs = jlMillisElapsed();
|
|
int16_t dcel = -1;
|
|
if (!gRender.deathActive) {
|
|
gRender.deathActive = true;
|
|
gRender.deathMs = nowMs;
|
|
gRender.deathStep = 0u;
|
|
}
|
|
if (!game->taxi.crashImpacted) {
|
|
dcel = (int16_t)((nowMs / ST_DEATH_TUMBLE_MS) & 1u);
|
|
gRender.deathMs = nowMs; // re-anchor for the walk
|
|
gRender.deathStep = 0u;
|
|
} else {
|
|
if (gRender.deathStep < 4u &&
|
|
nowMs - gRender.deathMs >= kDeathStepMs[gRender.deathStep]) {
|
|
gRender.deathMs = nowMs;
|
|
gRender.deathStep++;
|
|
}
|
|
if (gRender.deathStep < 4u) {
|
|
dcel = (int16_t)(2u + gRender.deathStep);
|
|
}
|
|
}
|
|
if (dcel >= 0 && gRender.deathCels[dcel] != NULL) {
|
|
jlSpriteSaveAndDraw(stage, gRender.deathCels[dcel], px, py,
|
|
&gRender.taxiBackup);
|
|
gRender.taxiHasBackup = true;
|
|
}
|
|
} else if (game->taxi.warpFrame > 0u) {
|
|
// Transporter shrink-warp: identity map onto the SEVEN drawn
|
|
// chain cels $E2..$E8; step 7 (pointer reaching $E9) hides
|
|
// the sprite entirely, exactly like $5CDA/$718E.
|
|
uint8_t step = stEngineWarpStep(game); /* 0..7 */
|
|
if (step < ST_WARP_CEL_COUNT && gRender.warpCels[step] != NULL) {
|
|
jlSpriteSaveAndDraw(stage, gRender.warpCels[step], px, py,
|
|
&gRender.taxiBackup);
|
|
gRender.taxiHasBackup = true;
|
|
}
|
|
} else if (gRender.taxiCels[taxiCel] != NULL) {
|
|
// Save-under + draw. Backup is replayed at the start of next
|
|
// frame above to undraw cleanly.
|
|
gRender.deathActive = false;
|
|
jlSpriteSaveAndDraw(stage, gRender.taxiCels[taxiCel], px, py,
|
|
&gRender.taxiBackup);
|
|
gRender.taxiHasBackup = true;
|
|
} else {
|
|
// No sprite asset: fall back to a placeholder rect AND mark
|
|
// the tilemap dirty so the level repaints over us next frame.
|
|
// Use the level's per-level sprite0Color ($D027) so the cab
|
|
// tint matches the level palette.
|
|
jlFillRect(stage, px, py, ST_TAXI_W_PX, ST_TAXI_H_PX,
|
|
game->level.sprite0Color);
|
|
gRender.tilemapDirty = true;
|
|
if (game->taxi.thrusting) {
|
|
jlFillRect(stage,
|
|
(int16_t)(px + ST_FLAME_OFFSET_X_PX),
|
|
(int16_t)(py + ST_FLAME_OFFSET_Y_PX),
|
|
ST_FLAME_W_PX,
|
|
ST_FLAME_H_PX,
|
|
game->level.sprite1Color);
|
|
}
|
|
}
|
|
|
|
// Passengers (waiting or being carried)
|
|
for (i = 0u; i < ST_MAX_PASSENGERS; i++) {
|
|
const StPassengerT *p = &game->passengers[i];
|
|
jlSpriteT *cel;
|
|
int16_t pcel;
|
|
if (!p->active || p->phase == ST_PASS_RIDING) {
|
|
continue; // riding passengers are invisible in the cab
|
|
}
|
|
// Cel by lifecycle phase (see StPassPhaseE): beams shrink or
|
|
// grow through $C7..$CB, the wait wave cycles $66D6, walks
|
|
// alternate the direction's stride pair.
|
|
switch (p->phase) {
|
|
case ST_PASS_BEAM_IN:
|
|
case ST_PASS_DROP_IN:
|
|
pcel = (int16_t)(7 - (p->waitPhase > 4u ? 4u : p->waitPhase));
|
|
break;
|
|
case ST_PASS_BOARD_OUT:
|
|
case ST_PASS_DROP_OUT:
|
|
pcel = (int16_t)(3 + (p->waitPhase > 4u ? 4u : p->waitPhase));
|
|
break;
|
|
case ST_PASS_WALK_TO_CAB:
|
|
case ST_PASS_WALK_TO_PAD:
|
|
if (p->targetX < p->x) {
|
|
pcel = (int16_t)((p->waitPhase & 1u) ? ST_PASSENGER_CEL_WALK1
|
|
: ST_PASSENGER_CEL_WALK0);
|
|
} else {
|
|
pcel = (int16_t)((p->waitPhase & 1u) ? ST_PASSENGER_CEL_WALKR1
|
|
: ST_PASSENGER_CEL_WALKR0);
|
|
}
|
|
break;
|
|
case ST_PASS_WAIT:
|
|
default:
|
|
pcel = passCelForPtr(kPassengerWaveCels[p->waitPhase & 3u]);
|
|
break;
|
|
}
|
|
cel = gRender.passengerCels[pcel];
|
|
if (cel == NULL) {
|
|
jlFillRect(stage, p->x, p->y,
|
|
ST_PASSENGER_W_PX, ST_PASSENGER_H_PX, 8u);
|
|
gRender.tilemapDirty = true;
|
|
continue;
|
|
}
|
|
jlSpriteSaveAndDraw(stage, cel, p->x, p->y,
|
|
&gRender.passengerBackup[i]);
|
|
gRender.passengerHasBackup[i] = true;
|
|
}
|
|
|
|
if (game->state == ST_STATE_DEMO) {
|
|
// Attract banners ($45A7-$4614 text): drawn every frame over
|
|
// the live demo, before the present.
|
|
stRenderDrawText(stage, 7u, 3u, "DEMO, USE JOYSTICK TO EXIT");
|
|
stRenderDrawText(stage, 3u, 24u, "THIS IS 1 OF 25 DIFFERENT SCREENS!");
|
|
}
|
|
|
|
if (game->state == ST_STATE_LEVEL_DONE) {
|
|
int16_t midY = (int16_t)(10 * ST_TILE_PIXELS);
|
|
jlFillRect(stage, 0, midY, SURFACE_WIDTH,
|
|
(int16_t)(2 * ST_TILE_PIXELS), 0u);
|
|
stRenderDrawText(stage, 13u, 10u, "LEVEL COMPLETE");
|
|
}
|
|
|
|
jlWaitVBL();
|
|
jlStagePresent();
|
|
}
|
|
|
|
|
|
// ----- internal helpers -----
|
|
|
|
static bool loadTileBank(uint8_t bankId) {
|
|
uint16_t idx;
|
|
uint16_t loaded;
|
|
char path[64];
|
|
|
|
snprintf(path, sizeof(path), ST_TILE_BANK_PATH_FMT, (unsigned)bankId);
|
|
jlLogF("stRender: loadTileBank(%u) -> %s", (unsigned)bankId, path);
|
|
|
|
// Reset the previous bank's validity bits so a smaller new bank
|
|
// doesn't inherit stale tiles past its end.
|
|
for (idx = 0u; idx < ST_TILE_BANK_MAX; idx++) {
|
|
gRender.tileValid[idx] = false;
|
|
}
|
|
gRender.bankLoaded = false;
|
|
gRender.currentBankId = bankId;
|
|
|
|
// Native bake: jlTileBankLoad fread's per-target planar tile
|
|
// bytes straight into gRender.tiles[].pixels with no chunky <->
|
|
// planar conversion. Replaces the JAS-load + jlSurfaceCreate +
|
|
// jlSurfaceBlit + per-tile jlTileSnap path, which was the
|
|
// dominant startup cost.
|
|
loaded = jlTileBankLoad(path, gRender.tiles, ST_TILE_BANK_MAX,
|
|
gRender.tileValid, NULL);
|
|
if (loaded == 0u) {
|
|
jlLogF("stRender: ! tile bank load failed (%s)", path);
|
|
return false;
|
|
}
|
|
jlLogF("stRender: tile bank loaded (%u tiles)", (unsigned)loaded);
|
|
gRender.bankLoaded = true;
|
|
return true;
|
|
}
|
|
|
|
|
|
static bool loadSpriteSheet(jlSurfaceT *stage) {
|
|
jlSpriteT *cels[ST_SPRITE_SHEET_CELS];
|
|
uint16_t count;
|
|
uint16_t i;
|
|
uint8_t step;
|
|
bool precompiled;
|
|
|
|
// .spr blob carries all 36 cels (9 cols x 4 rows of 3x3-tile
|
|
// chunky 4bpp blobs) in PNG reading order: row 0 taxi, row 1
|
|
// passenger, row 2 flame, row 3 warp. The padding slots between
|
|
// named sprites are loaded but never referenced; they leak a few
|
|
// KB at startup which is well below the cost of writing a
|
|
// free-loop here (extra code in _ROOT eats the IIgs cluster
|
|
// budget more than the heap fragments hurt).
|
|
memset(cels, 0, sizeof(cels));
|
|
// Prefer the pre-compiled bank: its cels arrive already compiled (native
|
|
// routines dropped straight into the codegen arena), so the jlSpriteCompile
|
|
// loop below is a no-op fast path. Fall back to the portable .spr + runtime
|
|
// JIT if no .spc is staged for this build.
|
|
count = jlSpriteBankLoadPrecompiled(ST_SPRITE_SHEET_SPC, cels, ST_SPRITE_SHEET_CELS, NULL);
|
|
precompiled = (count != 0u);
|
|
if (count == 0u) {
|
|
count = jlSpriteBankLoad(ST_SPRITE_SHEET_PATH, cels, ST_SPRITE_SHEET_CELS, NULL);
|
|
}
|
|
if (count == 0u) {
|
|
return false;
|
|
}
|
|
jlLogF("stRender: sprite sheet loaded (%u cels, %s)", (unsigned)count,
|
|
precompiled ? "PRECOMPILED" : "jit");
|
|
loadProgress(stage, 2u);
|
|
|
|
// Compile every drawn cel to its per-shift asm routine. Without
|
|
// this, jlSprite{Draw,SaveUnder,SaveAndDraw} fall to the per-pixel
|
|
// interpreter (sp->slot stays NULL) -- catastrophic on the IIgs
|
|
// 65816: the 3 title sprites alone cost ~200 ms/frame interpreted
|
|
// (~3.7 fps). Compiling routes them to the fast path (UBER's
|
|
// benchmarks prove codegen works on every port). jlSpriteCompile is
|
|
// a no-op fallback if the arena is exhausted (cel stays interpreted).
|
|
{
|
|
uint16_t compiled = 0u;
|
|
|
|
// Assign all cels first, then compile title-critical ones
|
|
// (taxi, passenger, flame) before the gameplay-only warp cels so
|
|
// a tight arena still fast-paths the title.
|
|
for (i = 0u; i < ST_TAXI_CEL_COUNT; i++) {
|
|
gRender.taxiCels[i] = cels[ST_SPRITE_TAXI_FIRST + i];
|
|
}
|
|
for (i = 0u; i < ST_WARP_CEL_COUNT; i++) {
|
|
gRender.warpCels[i] = cels[ST_SPRITE_WARP_FIRST + i];
|
|
}
|
|
for (i = 0u; i < ST_DEATH_CEL_COUNT; i++) {
|
|
gRender.deathCels[i] = cels[ST_SPRITE_DEATH_FIRST + i];
|
|
}
|
|
for (i = 0u; i < ST_PASSENGER_CEL_COUNT; i++) {
|
|
gRender.passengerCels[i] = cels[ST_SPRITE_PASS_FIRST + i];
|
|
}
|
|
for (i = 0u; i < ST_FLAME_CEL_COUNT; i++) {
|
|
gRender.flameCels[i] = cels[ST_SPRITE_FLAME_FIRST + i];
|
|
}
|
|
step = 3u;
|
|
for (i = 0u; i < ST_TAXI_CEL_COUNT; i++) {
|
|
compiled = (uint16_t)(compiled + (jlSpriteCompile(gRender.taxiCels[i]) ? 1u : 0u));
|
|
loadProgress(stage, step++);
|
|
}
|
|
for (i = 0u; i < ST_PASSENGER_CEL_COUNT; i++) {
|
|
compiled = (uint16_t)(compiled + (jlSpriteCompile(gRender.passengerCels[i]) ? 1u : 0u));
|
|
loadProgress(stage, step++);
|
|
}
|
|
for (i = 0u; i < ST_FLAME_CEL_COUNT; i++) {
|
|
compiled = (uint16_t)(compiled + (jlSpriteCompile(gRender.flameCels[i]) ? 1u : 0u));
|
|
loadProgress(stage, step++);
|
|
}
|
|
for (i = 0u; i < ST_WARP_CEL_COUNT; i++) {
|
|
compiled = (uint16_t)(compiled + (jlSpriteCompile(gRender.warpCels[i]) ? 1u : 0u));
|
|
loadProgress(stage, step++);
|
|
}
|
|
for (i = 0u; i < ST_DEATH_CEL_COUNT; i++) {
|
|
compiled = (uint16_t)(compiled + (jlSpriteCompile(gRender.deathCels[i]) ? 1u : 0u));
|
|
loadProgress(stage, step++);
|
|
}
|
|
jlLogF("stRender: sprite cels compiled: %u/%u", (unsigned)compiled,
|
|
(unsigned)(ST_TAXI_CEL_COUNT + ST_PASSENGER_CEL_COUNT + ST_FLAME_CEL_COUNT + ST_WARP_CEL_COUNT + ST_DEATH_CEL_COUNT));
|
|
}
|
|
|
|
// First-run cache: having just JIT-compiled the bank, write it out as a
|
|
// .spc so the next launch loads the compiled routines and skips the JIT
|
|
// (ship the small .spr, cache the big per-platform compiled bank). Best-
|
|
// effort -- a read-only/write-protected disk just means we JIT again.
|
|
if (!precompiled) {
|
|
if (jlSpriteBankSavePrecompiled(ST_SPRITE_SHEET_SPC, cels, count, NULL)) {
|
|
jlLogF("stRender: cached compiled bank -> %s", ST_SPRITE_SHEET_SPC);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
|
|
static void destroySprites(void) {
|
|
uint8_t i;
|
|
for (i = 0u; i < ST_TAXI_CEL_COUNT; i++) {
|
|
if (gRender.taxiCels[i] != NULL) {
|
|
jlSpriteDestroy(gRender.taxiCels[i]);
|
|
gRender.taxiCels[i] = NULL;
|
|
}
|
|
}
|
|
for (i = 0u; i < ST_DEATH_CEL_COUNT; i++) {
|
|
if (gRender.deathCels[i] != NULL) {
|
|
jlSpriteDestroy(gRender.deathCels[i]);
|
|
gRender.deathCels[i] = NULL;
|
|
}
|
|
}
|
|
for (i = 0u; i < ST_WARP_CEL_COUNT; i++) {
|
|
if (gRender.warpCels[i] != NULL) {
|
|
jlSpriteDestroy(gRender.warpCels[i]);
|
|
gRender.warpCels[i] = NULL;
|
|
}
|
|
}
|
|
for (i = 0u; i < ST_PASSENGER_CEL_COUNT; i++) {
|
|
if (gRender.passengerCels[i] != NULL) {
|
|
jlSpriteDestroy(gRender.passengerCels[i]);
|
|
gRender.passengerCels[i] = NULL;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// The 1000-glyph font streams straight onto the font surface via
|
|
// jlTileBankLoadToSurface -- no 32KB staging buffer. The old
|
|
// malloc(32000) was lethal on IIgs (the clang libc heap is ~665
|
|
// bytes and malloc returns 1, not NULL, at exhaustion -- LLVM816-ASKS
|
|
// item 4 -- so jlTileBankLoad fread the payload through pointer 1 and
|
|
// shredded bank 0 before the first present), and a 32KB static blows
|
|
// the bank-0 BSS ceiling. The streaming loader exists for exactly
|
|
// this case; its block grid matches ST_FONT_COLS row-major layout.
|
|
static bool loadFontSheet(jlSurfaceT *stage) {
|
|
uint16_t count;
|
|
uint16_t palette[16];
|
|
|
|
(void)stage;
|
|
gRender.fontSurface = jlSurfaceCreate();
|
|
if (gRender.fontSurface == NULL) {
|
|
return false;
|
|
}
|
|
count = jlTileBankLoadToSurface(ST_FONT_PATH, gRender.fontSurface, palette);
|
|
if (count == 0u) {
|
|
jlSurfaceDestroy(gRender.fontSurface);
|
|
gRender.fontSurface = NULL;
|
|
return false;
|
|
}
|
|
// Font palette is authoritative for the font surface so jlDrawText
|
|
// reads back the authored colors. Stage palette is untouched.
|
|
jlPaletteSet(gRender.fontSurface, 0u, palette);
|
|
jlScbSetRange(gRender.fontSurface, 0, SURFACE_HEIGHT - 1, 0);
|
|
gRender.fontReady = true;
|
|
return true;
|
|
}
|
|
|
|
|
|
// The C64 flame strobe ($716C: one game tick on, one off) mapped to
|
|
// wall-clock so every port shows the intended LOOK: at >=50fps this
|
|
// is the authentic 12.5Hz shimmer; when a rendered frame spans a
|
|
// whole strobe phase (low fps) the flame draws every frame -- the
|
|
// CRT-fusion appearance instead of an unreadable slow blink.
|
|
static bool flameStrobeOn(void) {
|
|
uint32_t nowMs = jlMillisElapsed();
|
|
bool phase = ((nowMs / ST_FLAME_STROBE_MS) & 1u) != 0u;
|
|
bool spans = (nowMs - gRender.flamePrevMs) >= ST_FLAME_STROBE_MS;
|
|
|
|
gRender.flamePrevMs = nowMs;
|
|
return phase || spans;
|
|
}
|
|
|
|
|
|
// Map a live C64 sprite-1 pointer value onto the sheet's passenger
|
|
// row (extractSprites.py pass_ptrs order):
|
|
// $C4..$CB -> cels 0..7 (walk cels, boarding shrink)
|
|
// $D9 -> cel 8 (the wave/sparkle cel)
|
|
// Single source of truth for the title path and the gameplay wave.
|
|
static int16_t passCelForPtr(uint8_t ptr) {
|
|
if (ptr == 0xD9u) {
|
|
return ST_PASSENGER_CEL_SPARKLE;
|
|
}
|
|
if (ptr == 0xC2u) {
|
|
return ST_PASSENGER_CEL_WALKR0;
|
|
}
|
|
if (ptr == 0xC3u) {
|
|
return ST_PASSENGER_CEL_WALKR1;
|
|
}
|
|
return (int16_t)ptr - 0xC4;
|
|
}
|
|
|
|
|
|
// Startup progress: outline drawn on step 0 so the screen shows life
|
|
// the moment stRenderInit runs, bar grows per completed step, LOADING
|
|
// caption appears once the font glyphs exist. Cheap enough to call
|
|
// per sprite compile even on the IIgs (present base ~3 ms).
|
|
static void loadProgress(jlSurfaceT *stage, uint8_t step) {
|
|
int16_t w;
|
|
|
|
if (step > (uint8_t)ST_LOAD_STEPS_TOTAL) {
|
|
step = (uint8_t)ST_LOAD_STEPS_TOTAL;
|
|
}
|
|
if (step == 0u) {
|
|
jlFillRect(stage, ST_LOAD_BAR_X - 2, ST_LOAD_BAR_Y - 2,
|
|
(uint16_t)(SURFACE_WIDTH - 2 * ST_LOAD_BAR_X + 4),
|
|
ST_LOAD_BAR_H + 4, ST_LOAD_BAR_FRAME_COLOR);
|
|
jlFillRect(stage, ST_LOAD_BAR_X - 1, ST_LOAD_BAR_Y - 1,
|
|
(uint16_t)(SURFACE_WIDTH - 2 * ST_LOAD_BAR_X + 2),
|
|
ST_LOAD_BAR_H + 2, 0u);
|
|
}
|
|
w = (int16_t)(((int32_t)step * (SURFACE_WIDTH - 2 * ST_LOAD_BAR_X)) / ST_LOAD_STEPS_TOTAL);
|
|
if (w > 0) {
|
|
jlFillRect(stage, ST_LOAD_BAR_X, ST_LOAD_BAR_Y, (uint16_t)w,
|
|
ST_LOAD_BAR_H, ST_LOAD_BAR_COLOR);
|
|
}
|
|
if (gRender.fontReady) {
|
|
stRenderDrawText(stage, 16u, 11u, "LOADING");
|
|
}
|
|
jlStagePresent();
|
|
}
|
|
|
|
|
|
// Build the ASCII -> (blockX | blockY << 8) lookup table used by
|
|
// jlDrawText. The font sheet was authored with each ASCII glyph at
|
|
// cell (ascii % 40, ascii / 40) (see assets/genPlaceholderArt.py),
|
|
// so the map is a direct computation. Control characters and DEL
|
|
// (0..31, 127) are marked TILE_NO_GLYPH so jlDrawText skips them.
|
|
static void buildAsciiMap(void) {
|
|
uint16_t i;
|
|
for (i = 0u; i < 128u; i++) {
|
|
if (i < 32u || i == 127u) {
|
|
gRender.asciiMap[i] = TILE_NO_GLYPH;
|
|
} else {
|
|
uint16_t col = (uint16_t)(i % ST_FONT_COLS);
|
|
uint16_t row = (uint16_t)(i / ST_FONT_COLS);
|
|
gRender.asciiMap[i] = (uint16_t)(col | (row << 8));
|
|
}
|
|
}
|
|
}
|